Compare commits
60 Commits
cojson-sim
...
cojson-sim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb855ed83d | ||
|
|
a8ef49e228 | ||
|
|
e0ad32dbd2 | ||
|
|
62bf769cad | ||
|
|
7488ff25b2 | ||
|
|
b69c9da983 | ||
|
|
d30fdef8aa | ||
|
|
9c5a6b9833 | ||
|
|
d300d265c4 | ||
|
|
1d72ce587f | ||
|
|
3fdb41dcb9 | ||
|
|
f20de2f04a | ||
|
|
31b31f111b | ||
|
|
2ae9fb9778 | ||
|
|
cd0da0f6bf | ||
|
|
cd9bfbb9fa | ||
|
|
ed0428bf97 | ||
|
|
c038a02051 | ||
|
|
31abcfeef4 | ||
|
|
5f32d9ccf5 | ||
|
|
0510600104 | ||
|
|
7f30fbf3c5 | ||
|
|
3d56260ca4 | ||
|
|
1137775da9 | ||
|
|
3951fdc938 | ||
|
|
5779e357dd | ||
|
|
2842d80f26 | ||
|
|
96387d8023 | ||
|
|
6720c19233 | ||
|
|
ef732b4700 | ||
|
|
ee7e3ee5a7 | ||
|
|
ceeed88fa5 | ||
|
|
79353a1d97 | ||
|
|
7fdc42c62f | ||
|
|
3a2e854a88 | ||
|
|
661a2d023a | ||
|
|
6ef5b6b2ab | ||
|
|
1384ebed84 | ||
|
|
17e53f9998 | ||
|
|
cfb1f39efe | ||
|
|
2234276dcf | ||
|
|
bb0a6a0600 | ||
|
|
0a6eb0c10a | ||
|
|
88b67d89e0 | ||
|
|
1a65d826b2 | ||
|
|
6c65ec2b46 | ||
|
|
5b578a832d | ||
|
|
042afc52d7 | ||
|
|
1b83493964 | ||
|
|
3b50da1a74 | ||
|
|
8e0fc74d9f | ||
|
|
e28326f32c | ||
|
|
d7e8b0b9da | ||
|
|
c46a1f6b0a | ||
|
|
7947918278 | ||
|
|
50c36e7255 | ||
|
|
c39a7ed1b7 | ||
|
|
83762dbb0f | ||
|
|
7c82e12508 | ||
|
|
6db149be36 |
63
.github/workflows/build-and-deploy.yaml
vendored
63
.github/workflows/build-and-deploy.yaml
vendored
@@ -7,8 +7,11 @@ on:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
example: ["todo", "pets"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -17,40 +20,50 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 16
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: yarn.lock
|
||||
- name: Nuke Workspace
|
||||
run: |
|
||||
rm package.json yarn.lock;
|
||||
- name: Yarn Build
|
||||
run: |
|
||||
yarn install --frozen-lockfile;
|
||||
yarn build;
|
||||
working-directory: ./examples/todo
|
||||
|
||||
- uses: satackey/action-docker-layer-caching@v0.0.11
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: docker-layer-caching-${{ github.workflow }}-{hash}
|
||||
restore-keys: |
|
||||
docker-layer-caching-${{ github.workflow }}-
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: gardencmp
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Build & Push
|
||||
- name: Nuke Workspace
|
||||
run: |
|
||||
export DOCKER_TAG=ghcr.io/gardencmp/jazz-example-todo:${{github.head_ref || github.ref_name}}-${{github.sha}}-$(date +%s) ;
|
||||
docker build . --file Dockerfile --tag $DOCKER_TAG;
|
||||
docker push $DOCKER_TAG;
|
||||
echo "DOCKER_TAG=$DOCKER_TAG" >> $GITHUB_ENV
|
||||
working-directory: ./examples/todo
|
||||
rm package.json yarn.lock;
|
||||
|
||||
- name: Yarn Build
|
||||
run: |
|
||||
yarn install --frozen-lockfile;
|
||||
yarn build;
|
||||
working-directory: ./examples/${{ matrix.example }}
|
||||
|
||||
- name: Docker Build & Push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./examples/${{ matrix.example }}
|
||||
push: true
|
||||
tags: ghcr.io/gardencmp/${{github.event.repository.name}}-example-${{ matrix.example }}:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
strategy:
|
||||
matrix:
|
||||
example: ["todo", "pets"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
- uses: gacts/install-nomad@v1
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v1
|
||||
@@ -69,9 +82,9 @@ jobs:
|
||||
|
||||
export DOCKER_USER=gardencmp;
|
||||
export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
|
||||
export DOCKER_TAG=${{ env.DOCKER_TAG }};
|
||||
export DOCKER_TAG=ghcr.io/gardencmp/${{github.event.repository.name}}-example-${{ matrix.example }}:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}};
|
||||
|
||||
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
|
||||
cat job-instance.nomad;
|
||||
NOMAD_ADDR='http://control1v2-london:4646' nomad job run job-instance.nomad;
|
||||
working-directory: ./examples/todo
|
||||
working-directory: ./examples/${{ matrix.example }}
|
||||
126
README.md
126
README.md
@@ -1,82 +1,116 @@
|
||||
# Jazz - instant sync
|
||||
|
||||
Homepage: [jazz.tools](https://jazz.tools) — [Discord](https://discord.gg/utDMjHYg42)
|
||||
<sub>Homepage: [jazz.tools](https://jazz.tools) — Docs: [DOCS.md](./DOCS.md) — Community & support: [Discord](https://discord.gg/utDMjHYg42) — Updates: [Twitter](https://twitter.com/jazz_tools) & [Email](https://gcmp.io/news)</sub>
|
||||
|
||||
Jazz is an open-source toolkit for *secure telepathic data.*
|
||||
**Jazz is an open-source toolkit for building apps with *secure sync.***
|
||||
|
||||
- Ship faster & simplify your frontend and backend
|
||||
- Get cross-device sync, real-time collaboration & offline support for free
|
||||
Quickly build and ship apps with:
|
||||
|
||||
[Jazz Global Mesh](https://jazz.tools/mesh) is serverless sync & storage for Jazz apps. (currently free!)
|
||||
- **Cross-device sync**
|
||||
- **Collaborative features** (incl. real-time multiplayer)
|
||||
- **Instantly reacting UIs**
|
||||
- Local-first storage & offline support
|
||||
- File upload and real-time media streaming
|
||||
|
||||
# What is *secure sync*?
|
||||
|
||||
**Sync** means that, *instead of making API requests*, you:
|
||||
|
||||
- **Read and write data as if it was local** — from anywhere in your app.
|
||||
- **Always have data synced to wherever it's needed, instantly:** to other devices of the same user, to other users, to your backend, to your local machine for debugging, etc.
|
||||
|
||||
**Secure** means that, *instead of relying on your API or DB for access control*, you:
|
||||
|
||||
- **Set fine-grained, role-based permissions in `Group`s** that are **synced along with your data**.
|
||||
- **Permissions *verifiably enforced* everywhere,** using encryption & signatures under the hood.
|
||||
- **Change roles dynamically** for evolving teams, expiring invite links and more.
|
||||
|
||||
# What's special about Jazz?
|
||||
|
||||
Compared to other libraries and frameworks for local-first, sync-based or real-time apps, these are some of the things that make Jazz unique:
|
||||
|
||||
- **Jazz is a *batteries-included,* vertically integrated toolkit,** offering everything you need to build an app, including auth, permissions, data model, sync, conflict resolution, blob storage, file uploads, real-time media streaming and more.
|
||||
- **Jazz has a *small API surface* of only a few abstractions to learn,** which combine in powerful ways to implement a broad set of features.
|
||||
- **Jazz *granularly* loads and caches *only the data that is needed*,** combining *local-first* instant UI reactivity and offline support with the on-demand data efficiency of conventional APIs
|
||||
- **Jazz supports end-to-end encryption, but doesn't require it,** allowing you to either manage your user's secret keys for them (based on existing auth flows) or letting your users
|
||||
- **Jazz is based on CoJSON, a soon-to-be *open standard,*** which means that there will be a whole ecosystem of compatible libraries and frameworks in a variety of environments — and it will be easy to achieve (secure) interop between Jazz/CoJSON-based apps and services.
|
||||
|
||||
# Jazz Global Mesh
|
||||
|
||||
Jazz is open source and you can run your own sync & storage server, but to really provide you with everything you need, we're also running
|
||||
**[Jazz Global Mesh](https://jazz.tools/mesh)**, a globally distributed mesh of servers optimized for:
|
||||
|
||||
- **Ultra-low-latency sync** (with geo-aware edge caching and optimal routing)
|
||||
- **Low-cost, reliable storage**
|
||||
|
||||
|
||||
**Jazz Global Mesh is free for small volumes of data** and it's the **default syncing peer,** so you can **start building multi-user Jazz apps with persistent data in minutes,** using only frontend code!
|
||||
|
||||
## What is Secure Telepathic Data?
|
||||
# Getting started
|
||||
|
||||
**Telepathic** means:
|
||||
## Example App Walkthrough
|
||||
|
||||
- **Read and write data as if it was local,** from anywhere in your app.
|
||||
- **Always have that data synced, instantly.** Across devices of the same user — or to other users (coming soon: to your backend, workers, etc.)
|
||||
**For now the best tutorial is the walkthrough of the [Todo List Example App](#todo-list).**
|
||||
|
||||
**Secure** means:
|
||||
## General Scenarios
|
||||
|
||||
- **Fine-grained, role-based permissions are *baked into* your data.**
|
||||
- **Permissions are enforced everywhere, locally.** (using cryptography instead of through an API)
|
||||
- Roles can be changed dynamically, supporting changing teams, invite links and more.
|
||||
### Building a new, entirely sync-based React app
|
||||
|
||||
## How to build an app with Jazz?
|
||||
1. Define your data model with [cojson Collaborative Values (CoValues)](./DOCS.md/#covalue).
|
||||
2. Implement permission logic using [cojson Groups](./DOCS.md/#group).
|
||||
3. Build a user interface with [jazz-react](./DOCS.md/#jazz-react)'s reactive [synced queries](./DOCS.md/#usesyncedqueryid).
|
||||
|
||||
### Building a new app, completely with Jazz
|
||||
### Gradually adding sync to an existing React app
|
||||
|
||||
It's still a bit early, but these are the rough steps:
|
||||
Gradually migrate app features to use sync:
|
||||
|
||||
1. Define your data model with [CoJSON Values](#cojson).
|
||||
2. Implement permission logic using [CoJSON Groups](#group).
|
||||
3. Hook up a user interface with [jazz-react](#jazz-react).
|
||||
1. Define data model for small aspect of your app with [cojson Collaborative Values (CoValues)](./DOCS.md/#covalue).
|
||||
- Schema adapters/importers for Prisma/Drizzle/PostgreSQL introspection coming soon.
|
||||
2. Map existing permission logic with [cojson Groups](./DOCS.md/#group) & integrate existing auth.
|
||||
- Auth integrations coming soon.
|
||||
3. Replace some of the React state and API requests in your UI with [jazz-react](./DOCS.md/#jazz-react)'s reactive [synced queries](./DOCS.md/#usesyncedqueryid).
|
||||
|
||||
The best example is currently the [Todo List app](#example-app-todo-list).
|
||||
# Example Apps
|
||||
|
||||
### Gradually adding Jazz to an existing app
|
||||
## Todo List
|
||||
|
||||
Coming soon: Jazz will support gradual adoption by integrating with your existing UI, auth and database.
|
||||
**A simple collaborative todo list app.**
|
||||
|
||||
## Example App: Todo List
|
||||
Live version: https://example-todo.jazz.tools
|
||||
|
||||
The best example of Jazz is currently the Todo List app.
|
||||
Source code & walkthrough: [`./examples/todo`](./examples/todo)
|
||||
|
||||
- Live version: https://example-todo.jazz.tools
|
||||
- Source code: [`./examples/todo`](./examples/todo). See the README there for a walk-through and running instructions.
|
||||
Demonstrates:
|
||||
- Defining a data model with `CoMap`s and `CoList`s
|
||||
- Creating data and setting permissions with `Group`s
|
||||
- Fetching, rendering & editing data from nested `CoValue`s with reactive synced queries
|
||||
|
||||
# Documentation
|
||||
|
||||
Note: Since it's early days, this is the only source of documentation so far.
|
||||
## Rate-My-Pet
|
||||
|
||||
If you want to build something with Jazz, [join the Jazz Discord](https://discord.gg/utDMjHYg42) for encouragement and help!
|
||||
**A simple social polling app.**
|
||||
|
||||
## Overview: Main Packages
|
||||
Live version: https://example-pets.jazz.tools
|
||||
|
||||
**`cojson`** → [DOCS](./DOCS.md#cojson)
|
||||
Source code (walkthrough coming soon): [`./examples/pets`](./examples/pets)
|
||||
|
||||
A library implementing abstractions and protocols for "Collaborative JSON". This will soon be standardized and forms the basis of secure telepathic data.
|
||||
Demonstrates:
|
||||
- Implementing per-account data streams (reactions) with `CoStream`s
|
||||
- Implementing image upload and progressive image streaming using helpers from `jazz-react-media-images` (on top of CoJSON's `BinaryCoStreams` & `ImageDefinition` convention)
|
||||
|
||||
**`jazz-react`** → [DOCS](./DOCS.md#jazz-react)
|
||||
|
||||
Provides you with everything you need to build react apps around CoJSON, including reactive hooks for telepathic data, local IndexedDB persistence, support for different auth providers and helpers for simple invite links for CoJSON groups.
|
||||
# Documentation & API Reference
|
||||
|
||||
### Supporting packages
|
||||
<small>
|
||||
For now, docs are hosted in a single well-structured markdown file: [`./DOCS.md`](./DOCS.md).
|
||||
|
||||
**`cojson-simple-sync`**
|
||||
- [Package Overview](./DOCS.md/#overview)
|
||||
- [`jazz-react` API](./DOCS.md/#jazz-react)
|
||||
- [`cojson` API](./DOCS.md/#cojson)
|
||||
- [`jazz-react-media-images` API](./DOCS.md/#jazz-react-media-images)
|
||||
|
||||
A generic CoJSON sync server you can run locally if you don't want to use Jazz Global Mesh (the default sync backend, at `wss://sync.jazz.tools`)
|
||||
|
||||
**`jazz-browser`** → [DOCS](./DOCS.md#jazz-browser)
|
||||
In the future we'll build a dedicated docs page on the Jazz homepage.
|
||||
|
||||
framework-agnostic primitives that allow you to use CoJSON in the browser. Used to implement `jazz-react`, will be used to implement bindings for other frameworks in the future.
|
||||
----
|
||||
|
||||
**`jazz-react-auth-local`** (and `jazz-browser-auth-local`): A simple auth provider that stores cryptographic keys on user devices using WebAuthentication/Passkeys. Lets you build Jazz apps completely without a backend, with end-to-end encryption by default.
|
||||
|
||||
**`jazz-storage-indexeddb`**
|
||||
|
||||
Provides local, offline-capable persistence. Included and enabled in `jazz-react` by default.
|
||||
</small>
|
||||
Copyright 2023: Garden Computing, Inc.
|
||||
@@ -1,14 +1,14 @@
|
||||
# Jazz Todo List Example
|
||||
# Jazz Rate-My-Pet List Example
|
||||
|
||||
Live version: https://example-todo.jazz.tools
|
||||
Live version: https://example-pets.jazz.tools
|
||||
|
||||
## Installing & running the example locally
|
||||
|
||||
Start by checking out just the example app to a folder:
|
||||
|
||||
```bash
|
||||
npx degit gardencmp/jazz/examples/todo jazz-example-todo
|
||||
cd jazz-example-todo
|
||||
npx degit gardencmp/jazz/examples/pets jazz-example-pets
|
||||
cd jazz-example-pets
|
||||
```
|
||||
|
||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
||||
@@ -27,31 +27,17 @@ npm run dev
|
||||
|
||||
## Structure
|
||||
|
||||
- [`src/basicComponents`](./src/basicComponents) contains simple components to build the UI, unrelated to Jazz (powered by [shadcn/ui](https://ui.shadcn.com))
|
||||
- [`src/components`](./src/components/) contains helper components that do contain Jazz-specific logic, but are not super relevant to understand the basics of Jazz and CoJSON
|
||||
- [`src/0_main.tsx`](./src/0_main.tsx), [`src/1_types.ts`](./src/1_types.ts), [`src/2_App.tsx`](./src/2_App.tsx), [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx), [`src/router.ts`](./src/router.ts) - the main files for this example, see the walkthrough below
|
||||
TODO
|
||||
|
||||
## Walkthrough
|
||||
|
||||
### Main parts
|
||||
|
||||
- The top-level provider `<WithJazz/>`: [`src/0_main.tsx`](./src/0_main.tsx)
|
||||
|
||||
- Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
|
||||
|
||||
- Creating todo projects & routing in `<App/>`: [`src/2_App.tsx`](./src/2_App.tsx)
|
||||
|
||||
- Reactively rendering a todo project as a table, adding and editing tasks: [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx)
|
||||
TODO
|
||||
|
||||
### Helpers
|
||||
|
||||
- Getting user profiles in `<NameBadge/>`: [`src/components/NameBadge.tsx`](./src/components/NameBadge.tsx)
|
||||
|
||||
- (not yet commented) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
|
||||
|
||||
- (not yet commented) `location.hash`-based routing and accepting invite links with `useSimpleHashRouterThatAcceptsInvites()` in [`src/router.ts`](./src/router.ts)
|
||||
|
||||
This is the whole Todo List app!
|
||||
TODO
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/0_main.tsx"></script>
|
||||
<script type="module" src="/src/2_main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
job "example-todo$BRANCH_SUFFIX" {
|
||||
job "example-pets$BRANCH_SUFFIX" {
|
||||
region = "global"
|
||||
datacenters = ["*"]
|
||||
|
||||
@@ -41,7 +41,7 @@ job "example-todo$BRANCH_SUFFIX" {
|
||||
|
||||
service {
|
||||
tags = ["public"]
|
||||
name = "example-todo$BRANCH_SUFFIX"
|
||||
name = "example-pets$BRANCH_SUFFIX"
|
||||
port = "http"
|
||||
provider = "consul"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-pets",
|
||||
"private": true,
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.13",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,16 +16,18 @@
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.1.14",
|
||||
"jazz-react-auth-local": "^0.1.14",
|
||||
"jazz-react": "^0.3.4",
|
||||
"jazz-react-auth-local": "^0.3.4",
|
||||
"jazz-react-media-images": "^0.3.4",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uniqolor": "^1.1.0",
|
||||
"use-debounce": "^9.0.4"
|
||||
"uniqolor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
|
||||
import { WithJazz } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
import { ThemeProvider, TitleAndLogo } from "./basicComponents/index.ts";
|
||||
import { PrettyAuthUI } from "./components/Auth.tsx";
|
||||
import App from "./2_App.tsx";
|
||||
|
||||
/** Walkthrough: The top-level provider `<WithJazz/>`
|
||||
*
|
||||
* This shows how to use the top-level provider `<WithJazz/>`,
|
||||
* which provides the rest of the app with a `LocalNode` (used through `useJazz` later),
|
||||
* based on `LocalAuth` that uses PassKeys (aka WebAuthn) to store a user's account secret
|
||||
* - no backend needed. */
|
||||
|
||||
const appName = "Jazz Rate My Pet Example";
|
||||
|
||||
const auth = LocalAuth({
|
||||
appName,
|
||||
Component: PrettyAuthUI,
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
|
||||
<WithJazz auth={auth}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
/** Walkthrough: Continue with ./1_types.ts */
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CoMap, CoID, BinaryCoStream, CoStream } from "cojson";
|
||||
import { CoMap, CoStream, Media } from "cojson";
|
||||
|
||||
/** Walkthrough: Defining the data model with CoJSON
|
||||
*
|
||||
@@ -9,18 +9,20 @@ import { CoMap, CoID, BinaryCoStream, CoStream } from "cojson";
|
||||
|
||||
export type PetPost = CoMap<{
|
||||
name: string;
|
||||
image: CoID<BinaryCoStream>;
|
||||
reactions: CoID<PetReactions>;
|
||||
image: Media.ImageDefinition;
|
||||
reactions: PetReactions;
|
||||
}>;
|
||||
|
||||
export type ReactionType =
|
||||
| "aww"
|
||||
| "love"
|
||||
| "haha"
|
||||
| "wow"
|
||||
| "tiny"
|
||||
| "chonkers"
|
||||
| "good";
|
||||
export const REACTION_TYPES = [
|
||||
"aww",
|
||||
"love",
|
||||
"haha",
|
||||
"wow",
|
||||
"tiny",
|
||||
"chonkers",
|
||||
] as const;
|
||||
|
||||
export type ReactionType = (typeof REACTION_TYPES)[number];
|
||||
|
||||
export type PetReactions = CoStream<ReactionType>;
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { PetPost } from "./1_types";
|
||||
|
||||
import { Button } from "./basicComponents";
|
||||
|
||||
import { useSimpleHashRouterThatAcceptsInvites } from "./router";
|
||||
import { PetPostUI } from "./4_PetPostUI";
|
||||
import { CreatePetPostForm } from "./4_CreatePetPostForm";
|
||||
|
||||
/** Walkthrough: Creating pet posts & routing in `<App/>`
|
||||
*
|
||||
* <App> is the main app component, handling client-side routing based
|
||||
* on the CoValue ID (CoID) of our PetPost, stored in the URL hash
|
||||
* - which can also contain invite links.
|
||||
*/
|
||||
|
||||
export default function App() {
|
||||
// A `LocalNode` represents a local view of loaded & created CoValues.
|
||||
// It is associated with a current user account, which will determine
|
||||
// access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
|
||||
const { localNode, logOut } = useJazz();
|
||||
|
||||
// This sets up routing and accepting invites, skip for now
|
||||
const [currentPetPostID, navigateToPetPostID] =
|
||||
useSimpleHashRouterThatAcceptsInvites<PetPost>(localNode);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
{currentPetPostID ? (
|
||||
<PetPostUI petPostID={currentPetPostID} />
|
||||
) : (
|
||||
<CreatePetPostForm onCreate={navigateToPetPostID} />
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigateToPetPostID(undefined);
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: continue with ./3_TodoTable.tsx */
|
||||
86
examples/pets/src/2_main.tsx
Normal file
86
examples/pets/src/2_main.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createHashRouter } from "react-router-dom";
|
||||
import "./index.css";
|
||||
|
||||
import { WithJazz, useJazz, useAcceptInvite } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
import {
|
||||
Button,
|
||||
ThemeProvider,
|
||||
TitleAndLogo,
|
||||
} from "./basicComponents/index.ts";
|
||||
import { PrettyAuthUI } from "./components/Auth.tsx";
|
||||
import { NewPetPostForm } from "./3_NewPetPostForm.tsx";
|
||||
import { RatePetPostUI } from "./4_RatePetPostUI.tsx";
|
||||
|
||||
/** Walkthrough: The top-level provider `<WithJazz/>`
|
||||
*
|
||||
* This shows how to use the top-level provider `<WithJazz/>`,
|
||||
* which provides the rest of the app with a `LocalNode` (used through `useJazz` later),
|
||||
* based on `LocalAuth` that uses PassKeys (aka WebAuthn) to store a user's account secret
|
||||
* - no backend needed. */
|
||||
|
||||
const appName = "Jazz Rate My Pet Example";
|
||||
|
||||
const auth = LocalAuth({
|
||||
appName,
|
||||
Component: PrettyAuthUI,
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<WithJazz auth={auth}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
/** Walkthrough: Creating pet posts & routing in `<App/>`
|
||||
*
|
||||
* <App> is the main app component, handling client-side routing based
|
||||
* on the CoValue ID (CoID) of our PetPost, stored in the URL hash
|
||||
* - which can also contain invite links.
|
||||
*/
|
||||
|
||||
export default function App() {
|
||||
const { logOut } = useJazz();
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <NewPetPostForm />,
|
||||
},
|
||||
{
|
||||
path: "/pet/:petPostId",
|
||||
element: <RatePetPostUI />,
|
||||
},
|
||||
{
|
||||
path: "/invite/*",
|
||||
element: <p>Accepting invite...</p>,
|
||||
},
|
||||
]);
|
||||
|
||||
useAcceptInvite((petPostID) => router.navigate("/pet/" + petPostID));
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
<RouterProvider router={router} />
|
||||
|
||||
<Button
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: continue with ./3_CreatePetPostForm.tsx */
|
||||
|
||||
/** Walkthrough: Continue with ./1_types.ts */
|
||||
100
examples/pets/src/3_NewPetPostForm.tsx
Normal file
100
examples/pets/src/3_NewPetPostForm.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ChangeEvent, useCallback, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { CoID, CoMap, Media } from "cojson";
|
||||
import { useJazz, useSyncedQuery } from "jazz-react";
|
||||
import { createImage } from "jazz-browser-media-images";
|
||||
|
||||
import { PetReactions } from "./1_types";
|
||||
|
||||
import { Input, Button } from "./basicComponents";
|
||||
import { useLoadImage } from "jazz-react-media-images";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
*/
|
||||
|
||||
type PartialPetPost = CoMap<{
|
||||
name: string;
|
||||
image?: Media.ImageDefinition;
|
||||
reactions: PetReactions;
|
||||
}>;
|
||||
|
||||
export function NewPetPostForm() {
|
||||
const { localNode } = useJazz();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [newPostId, setNewPostId] = useState<
|
||||
CoID<PartialPetPost> | undefined
|
||||
>(undefined);
|
||||
|
||||
const newPetPost = useSyncedQuery(newPostId);
|
||||
|
||||
const onChangeName = useCallback(
|
||||
(name: string) => {
|
||||
if (newPetPost) {
|
||||
newPetPost.set({ name });
|
||||
} else {
|
||||
const petPostGroup = localNode.createGroup();
|
||||
const petPost = petPostGroup.createMap<PartialPetPost>({
|
||||
name,
|
||||
reactions: petPostGroup.createStream<PetReactions>(),
|
||||
});
|
||||
|
||||
setNewPostId(petPost.id);
|
||||
}
|
||||
},
|
||||
[localNode, newPetPost]
|
||||
);
|
||||
|
||||
const onImageSelected = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!newPetPost || !event.target.files) return;
|
||||
|
||||
const image = await createImage(
|
||||
event.target.files[0],
|
||||
newPetPost.group
|
||||
);
|
||||
|
||||
newPetPost.set({ image });
|
||||
},
|
||||
[newPetPost]
|
||||
);
|
||||
|
||||
const petImage = useLoadImage(newPetPost?.image?.id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<p>Share your pet with friends!</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Pet Name"
|
||||
className="text-3xl py-6"
|
||||
onChange={(event) => onChangeName(event.target.value)}
|
||||
value={newPetPost?.name || ""}
|
||||
/>
|
||||
|
||||
{petImage ? (
|
||||
<img
|
||||
className="w-80 max-w-full rounded"
|
||||
src={petImage.highestResSrc || petImage.placeholderDataURL}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="file"
|
||||
disabled={!newPetPost?.name}
|
||||
onChange={onImageSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{newPetPost?.name && newPetPost?.image && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate("/pet/" + newPetPost.id);
|
||||
}}
|
||||
>
|
||||
Submit Post
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { BinaryCoStream, CoID } from "cojson";
|
||||
import {
|
||||
useBinaryStream,
|
||||
useJazz,
|
||||
useTelepathicState,
|
||||
} from "jazz-react";
|
||||
|
||||
import { PetPost, PetReactions, ReactionType } from "./1_types";
|
||||
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
} from "./basicComponents";
|
||||
|
||||
import { InviteButton } from "./components/InviteButton";
|
||||
import { NameBadge } from "./components/NameBadge";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { createBinaryStreamHandler } from "jazz-react";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
*/
|
||||
|
||||
export function CreatePetPostForm({
|
||||
onCreate,
|
||||
}: {
|
||||
onCreate: (id: CoID<PetPost>) => void;
|
||||
}) {
|
||||
const { localNode } = useJazz();
|
||||
|
||||
const [creatingPostId, setCreatingPostId] = useState<
|
||||
CoID<PetPost> | undefined
|
||||
>(undefined);
|
||||
|
||||
const creatingPetPost = useTelepathicState(creatingPostId);
|
||||
|
||||
const onChangeName = useDebouncedCallback((name: string) => {
|
||||
let petPost = creatingPetPost;
|
||||
if (!petPost) {
|
||||
const petPostGroup = localNode.createGroup();
|
||||
petPost = petPostGroup.createMap<PetPost>();
|
||||
const reactions = petPostGroup.createStream<PetReactions>();
|
||||
|
||||
petPost = petPost.edit((petPost) => {
|
||||
petPost.set("reactions", reactions.id);
|
||||
});
|
||||
|
||||
setCreatingPostId(petPost.id);
|
||||
}
|
||||
|
||||
petPost.edit((petPost) => {
|
||||
petPost.set("name", name);
|
||||
});
|
||||
}, 200);
|
||||
|
||||
const onImageCreated = useCallback(
|
||||
(image: BinaryCoStream) => {
|
||||
if (!creatingPetPost) throw new Error("Never get here");
|
||||
creatingPetPost.edit((petPost) => {
|
||||
petPost.set("image", image.id);
|
||||
});
|
||||
},
|
||||
[creatingPetPost]
|
||||
);
|
||||
|
||||
const image = useBinaryStream(creatingPetPost?.get("image"));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Pet Name"
|
||||
onChange={event => onChangeName(event.target.value)}
|
||||
value={creatingPetPost?.get("name")}
|
||||
/>
|
||||
|
||||
{image ? (
|
||||
<img src={image.blobURL} />
|
||||
) : (
|
||||
creatingPetPost && (
|
||||
<Input
|
||||
type="file"
|
||||
onChange={createBinaryStreamHandler(
|
||||
onImageCreated,
|
||||
creatingPetPost.group
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{creatingPetPost?.get("name") && creatingPetPost?.get("image") && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onCreate(creatingPetPost.id);
|
||||
}}
|
||||
>
|
||||
Submit Post
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { CoID } from "cojson";
|
||||
import { useTelepathicState } from "jazz-react";
|
||||
|
||||
import { PetPost } from "./1_types";
|
||||
|
||||
import { InviteButton } from "./components/InviteButton";
|
||||
import { NameBadge } from "./components/NameBadge";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
*/
|
||||
|
||||
export function PetPostUI({ petPostID }: { petPostID: CoID<PetPost> }) {
|
||||
|
||||
|
||||
return (<div>TODO</div>);
|
||||
}
|
||||
124
examples/pets/src/4_RatePetPostUI.tsx
Normal file
124
examples/pets/src/4_RatePetPostUI.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useParams } from "react-router";
|
||||
import { CoID, Queried } from "cojson";
|
||||
import { useSyncedQuery } from "jazz-react";
|
||||
|
||||
import { PetPost, ReactionType, REACTION_TYPES, PetReactions } from "./1_types";
|
||||
|
||||
import { ShareButton } from "./components/ShareButton";
|
||||
import { Button, Skeleton } from "./basicComponents";
|
||||
import { useLoadImage } from "jazz-react-media-images";
|
||||
import uniqolor from "uniqolor";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
*/
|
||||
|
||||
const reactionEmojiMap: { [reaction in ReactionType]: string } = {
|
||||
aww: "😍",
|
||||
love: "❤️",
|
||||
haha: "😂",
|
||||
wow: "😮",
|
||||
tiny: "🐥",
|
||||
chonkers: "🐘",
|
||||
};
|
||||
|
||||
export function RatePetPostUI() {
|
||||
const petPostID = useParams<{ petPostId: CoID<PetPost> }>().petPostId;
|
||||
|
||||
const petPost = useSyncedQuery(petPostID);
|
||||
const petImage = useLoadImage(petPost?.image);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex justify-between">
|
||||
<h1 className="text-3xl font-bold">{petPost?.name}</h1>
|
||||
<ShareButton petPost={petPost} />
|
||||
</div>
|
||||
|
||||
{petImage && (
|
||||
<img
|
||||
className="w-80 max-w-full rounded"
|
||||
src={petImage.highestResSrc || petImage.placeholderDataURL}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between max-w-xs flex-wrap">
|
||||
{REACTION_TYPES.map((reactionType) => (
|
||||
<Button
|
||||
key={reactionType}
|
||||
variant={
|
||||
petPost?.reactions?.me?.last === reactionType
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => {
|
||||
petPost?.reactions?.push(reactionType);
|
||||
}}
|
||||
title={`React with ${reactionType}`}
|
||||
className="text-2xl px-2"
|
||||
>
|
||||
{reactionEmojiMap[reactionType]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{petPost?.group.myRole() === "admin" && petPost.reactions && (
|
||||
<ReactionOverview petReactions={petPost.reactions} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReactionOverview({
|
||||
petReactions,
|
||||
}: {
|
||||
petReactions: Queried<PetReactions>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Reactions</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{REACTION_TYPES.map((reactionType) => {
|
||||
const reactionsOfThisType = Object.values(
|
||||
petReactions.perAccount
|
||||
).filter(({ last }) => last === reactionType);
|
||||
|
||||
if (reactionsOfThisType.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center"
|
||||
key={reactionType}
|
||||
>
|
||||
{reactionEmojiMap[reactionType]}{" "}
|
||||
{reactionsOfThisType.map((reaction, idx) =>
|
||||
reaction.by?.profile?.name ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={uniqueColoring(reaction.by.id)}
|
||||
key={reaction.by.id}
|
||||
>
|
||||
{reaction.by.profile.name}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton
|
||||
className="mt-1 w-[50px] h-[1em] rounded-full"
|
||||
key={idx}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueColoring(seed: string) {
|
||||
const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
return {
|
||||
color: uniqolor(seed, { lightness: darkMode ? 80 : 20 }).color,
|
||||
background: uniqolor(seed, { lightness: darkMode ? 20 : 80 }).color,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
export function TitleAndLogo({name}: {name: string}) {
|
||||
return <>
|
||||
<div className="flex items-center gap-2 justify-center mt-5">
|
||||
import { Toaster } from ".";
|
||||
|
||||
export function TitleAndLogo({ name }: { name: string }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 justify-center mt-5">
|
||||
<img src="jazz-logo.png" className="h-5" /> {name}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export { Button } from "./ui/button";
|
||||
export { Input } from "./ui/input";
|
||||
export { Toaster } from "./ui/toaster";
|
||||
export { useToast } from "./ui/use-toast";
|
||||
export { Skeleton } from "./ui/skeleton";
|
||||
export { TitleAndLogo } from "./TitleAndLogo";
|
||||
export { ThemeProvider } from "./themeProvider";
|
||||
|
||||
15
examples/pets/src/basicComponents/ui/skeleton.tsx
Normal file
15
examples/pets/src/basicComponents/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
127
examples/pets/src/basicComponents/ui/toast.tsx
Normal file
127
examples/pets/src/basicComponents/ui/toast.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
33
examples/pets/src/basicComponents/ui/toaster.tsx
Normal file
33
examples/pets/src/basicComponents/ui/toaster.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/basicComponents/ui/toast"
|
||||
import { useToast } from "@/basicComponents/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
192
examples/pets/src/basicComponents/ui/use-toast.ts
Normal file
192
examples/pets/src/basicComponents/ui/use-toast.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/basicComponents/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
@@ -1,46 +0,0 @@
|
||||
import { AccountID } from "cojson";
|
||||
import { useProfile } from "jazz-react";
|
||||
|
||||
import { Skeleton } from "@/basicComponents";
|
||||
import uniqolor from "uniqolor";
|
||||
|
||||
/** Walkthrough: Getting user profiles in `<NameBadge/>`
|
||||
*
|
||||
* `<NameBadge/>` uses `useProfile(accountID)`, which is a shorthand for
|
||||
* useTelepathicState on an account's profile.
|
||||
*
|
||||
* Profiles are always a `CoMap<{name: string}>`, but they might have app-specific
|
||||
* additional properties).
|
||||
*
|
||||
* In our case, we just display the profile name (which is set by the LocalAuth
|
||||
* provider when we first create an account).
|
||||
*/
|
||||
|
||||
export function NameBadge({ accountID }: { accountID?: AccountID }) {
|
||||
const profile = useProfile(accountID);
|
||||
|
||||
return accountID && profile?.get("name") ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={randomUserColor(accountID)}
|
||||
>
|
||||
{profile.get("name")}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
|
||||
);
|
||||
}
|
||||
|
||||
function randomUserColor(accountID: AccountID) {
|
||||
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
|
||||
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
|
||||
|
||||
return {
|
||||
color: theme == "light" ? darkColor : brightColor,
|
||||
background: theme == "light" ? brightColor : darkColor,
|
||||
};
|
||||
}
|
||||
@@ -1,27 +1,28 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { TodoProject } from "../1_types";
|
||||
import { PetPost } from "../1_types";
|
||||
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import { useToast, Button } from "../basicComponents";
|
||||
import { Queried } from "cojson";
|
||||
|
||||
export function InviteButton({ list }: { list?: TodoProject }) {
|
||||
export function ShareButton({ petPost }: { petPost?: Queried<PetPost> }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
list?.group.myRole() === "admin" && (
|
||||
petPost?.group.myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
disabled={!petPost}
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (list && !inviteLink) {
|
||||
inviteLink = createInviteLink(list, "writer");
|
||||
if (petPost && !inviteLink) {
|
||||
inviteLink = createInviteLink(petPost, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
@@ -39,7 +40,7 @@ export function InviteButton({ list }: { list?: TodoProject }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
Share
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CoID, LocalNode, CoValueImpl } from "cojson";
|
||||
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
|
||||
|
||||
export function useSimpleHashRouterThatAcceptsInvites<C extends CoValueImpl>(
|
||||
localNode: LocalNode
|
||||
) {
|
||||
const [currentValueId, setCurrentValueId] = useState<CoID<C>>();
|
||||
|
||||
useEffect(() => {
|
||||
const listener = async () => {
|
||||
const acceptedInvitation = await consumeInviteLinkFromWindowLocation<C>(localNode);
|
||||
|
||||
if (acceptedInvitation) {
|
||||
setCurrentValueId(acceptedInvitation.valueID);
|
||||
window.location.hash = acceptedInvitation.valueID;
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentValueId(
|
||||
(window.location.hash.slice(1) as CoID<C>) || undefined
|
||||
);
|
||||
};
|
||||
window.addEventListener("hashchange", listener);
|
||||
listener();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", listener);
|
||||
};
|
||||
}, [localNode]);
|
||||
|
||||
const navigateToValue = useCallback((id: CoID<C> | undefined) => {
|
||||
window.location.hash = id || "";
|
||||
}, []);
|
||||
|
||||
return [currentValueId, navigateToValue] as const;
|
||||
}
|
||||
@@ -27,29 +27,28 @@ npm run dev
|
||||
|
||||
## Structure
|
||||
|
||||
- [`src/basicComponents`](./src/basicComponents) contains simple components to build the UI, unrelated to Jazz (powered by [shadcn/ui](https://ui.shadcn.com))
|
||||
- [`src/components`](./src/components/) contains helper components that do contain Jazz-specific logic, but are not super relevant to understand the basics of Jazz and CoJSON
|
||||
- [`src/0_main.tsx`](./src/0_main.tsx), [`src/1_types.ts`](./src/1_types.ts), [`src/2_App.tsx`](./src/2_App.tsx), [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx), [`src/router.ts`](./src/router.ts) - the main files for this example, see the walkthrough below
|
||||
- [`src/basicComponents`](./src/basicComponents): simple components to build the UI, unrelated to Jazz (uses [shadcn/ui](https://ui.shadcn.com))
|
||||
- [`src/components`](./src/components/): helper components that do contain Jazz-specific logic, but aren't very relevant to understand the basics of Jazz and CoJSON
|
||||
- [`src/1_types.ts`](./src/1_types.ts),
|
||||
[`src/2_main.tsx`](./src/2_main.tsx),
|
||||
[`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx),
|
||||
[`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx): the main files for this example, see the walkthrough below
|
||||
|
||||
## Walkthrough
|
||||
|
||||
### Main parts
|
||||
|
||||
- The top-level provider `<WithJazz/>`: [`src/0_main.tsx`](./src/0_main.tsx)
|
||||
1. Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
|
||||
|
||||
- Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
|
||||
2. The top-level provider `<WithJazz/>` and routing: [`src/2_main.tsx`](./src/2_main.tsx)
|
||||
|
||||
- Creating todo projects & routing in `<App/>`: [`src/2_App.tsx`](./src/2_App.tsx)
|
||||
3. Creating a new todo project: [`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx)
|
||||
|
||||
- Reactively rendering a todo project as a table, adding and editing tasks: [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx)
|
||||
4. Reactively rendering a todo project as a table, adding and editing tasks: [`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx)
|
||||
|
||||
### Helpers
|
||||
|
||||
- Getting user profiles in `<NameBadge/>`: [`src/components/NameBadge.tsx`](./src/components/NameBadge.tsx)
|
||||
|
||||
- (not yet commented) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
|
||||
|
||||
- (not yet commented) `location.hash`-based routing and accepting invite links with `useSimpleHashRouterThatAcceptsInvites()` in [`src/router.ts`](./src/router.ts)
|
||||
- (not yet explained) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
|
||||
|
||||
This is the whole Todo List app!
|
||||
|
||||
@@ -62,4 +61,4 @@ If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or
|
||||
|
||||
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/0_main.tsx](./src/0_main.tsx).
|
||||
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/2_main.tsx](./src/2_main.tsx).
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/0_main.tsx"></script>
|
||||
<script type="module" src="/src/2_main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.28",
|
||||
"version": "0.0.38",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,12 +16,14 @@
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.1.14",
|
||||
"jazz-react-auth-local": "^0.1.14",
|
||||
"jazz-react": "^0.3.4",
|
||||
"jazz-react-auth-local": "^0.3.4",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uniqolor": "^1.1.0"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
|
||||
import { WithJazz } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
import { ThemeProvider, TitleAndLogo } from "./basicComponents/index.ts";
|
||||
import { PrettyAuthUI } from "./components/Auth.tsx";
|
||||
import App from "./2_App.tsx";
|
||||
|
||||
/** Walkthrough: The top-level provider `<WithJazz/>`
|
||||
*
|
||||
* This shows how to use the top-level provider `<WithJazz/>`,
|
||||
* which provides the rest of the app with a `LocalNode` (used through `useJazz` later),
|
||||
* based on `LocalAuth` that uses PassKeys (aka WebAuthn) to store a user's account secret
|
||||
* - no backend needed. */
|
||||
|
||||
const appName = "Jazz Todo List Example";
|
||||
|
||||
const auth = LocalAuth({
|
||||
appName,
|
||||
Component: PrettyAuthUI,
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
|
||||
<WithJazz auth={auth}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
/** Walkthrough: Continue with ./1_types.ts */
|
||||
@@ -1,28 +1,23 @@
|
||||
import { CoMap, CoList, CoID } from "cojson";
|
||||
import { CoMap, CoList } from "cojson";
|
||||
|
||||
/** Walkthrough: Defining the data model with CoJSON
|
||||
*
|
||||
* Here, we define our main data model of tasks, lists of tasks and projects
|
||||
* using CoJSON's collaborative map and list types, CoMap & CoList.
|
||||
*
|
||||
* CoMap values and CoLists items can be:
|
||||
* CoMap values and CoLists items can contain:
|
||||
* - arbitrary immutable JSON
|
||||
* - references to other CoValues by their CoID
|
||||
* - CoIDs are strings that look like `co_zXPuWmH1D1cKdMpDW6CMzWb3LpY`
|
||||
* - In TypeScript, CoIDs take a generic parameter for the type of the
|
||||
* referenced CoValue, e.g. `CoID<Task>` - to make the references precise
|
||||
* - references to other CoValues (internally stored by their CoID)
|
||||
**/
|
||||
|
||||
/** An individual task which collaborators can tick or rename */
|
||||
export type Task = CoMap<{ done: boolean; text: string; }>;
|
||||
|
||||
/** A collaborative, ordered list of task references */
|
||||
export type ListOfTasks = CoList<CoID<Task>>;
|
||||
|
||||
/** Our top level object: a project with a title, referencing a list of tasks */
|
||||
export type TodoProject = CoMap<{
|
||||
title: string;
|
||||
tasks: CoID<ListOfTasks>;
|
||||
/** A collaborative, ordered list of tasks */
|
||||
tasks: CoList<Task>;
|
||||
}>;
|
||||
|
||||
/** Walkthrough: Continue with ./2_App.tsx */
|
||||
/** Walkthrough: Continue with ./2_main.tsx */
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { TodoProject, ListOfTasks } from "./1_types";
|
||||
|
||||
import { SubmittableInput, Button } from "./basicComponents";
|
||||
|
||||
import { useSimpleHashRouterThatAcceptsInvites } from "./router";
|
||||
import { TodoTable } from "./3_TodoTable";
|
||||
|
||||
/** Walkthrough: Creating todo projects & routing in `<App/>`
|
||||
*
|
||||
* <App> is the main app component, handling client-side routing based
|
||||
* on the CoValue ID (CoID) of our TodoProject, stored in the URL hash
|
||||
* - which can also contain invite links.
|
||||
*/
|
||||
|
||||
export default function App() {
|
||||
// A `LocalNode` represents a local view of loaded & created CoValues.
|
||||
// It is associated with a current user account, which will determine
|
||||
// access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
|
||||
const { localNode, logOut } = useJazz();
|
||||
|
||||
// This sets up routing and accepting invites, skip for now
|
||||
const [currentProjectId, navigateToProjectId] =
|
||||
useSimpleHashRouterThatAcceptsInvites<TodoProject>(localNode);
|
||||
|
||||
const createProject = useCallback(
|
||||
(title: string) => {
|
||||
if (!title) return;
|
||||
|
||||
// To create a new todo project, we first create a `Group`,
|
||||
// which is a scope for defining access rights (reader/writer/admin)
|
||||
// of its members, which will apply to all CoValues owned by that group.
|
||||
const projectGroup = localNode.createGroup();
|
||||
|
||||
// Then we create an empty todo project and list of tasks within that group.
|
||||
const project = projectGroup.createMap<TodoProject>();
|
||||
const tasks = projectGroup.createList<ListOfTasks>();
|
||||
|
||||
// We edit the todo project to initialise it.
|
||||
// Inside the `.edit` callback we can mutate a CoValue
|
||||
project.edit((project) => {
|
||||
project.set("title", title);
|
||||
project.set("tasks", tasks.id);
|
||||
});
|
||||
|
||||
navigateToProjectId(project.id);
|
||||
},
|
||||
[localNode, navigateToProjectId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
{currentProjectId ? (
|
||||
<TodoTable projectId={currentProjectId} />
|
||||
) : (
|
||||
<SubmittableInput
|
||||
onSubmit={createProject}
|
||||
label="Create New Project"
|
||||
placeholder="New project title"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigateToProjectId(undefined);
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: continue with ./3_TodoTable.tsx */
|
||||
91
examples/todo/src/2_main.tsx
Normal file
91
examples/todo/src/2_main.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createHashRouter } from "react-router-dom";
|
||||
import "./index.css";
|
||||
|
||||
import { WithJazz, useJazz, useAcceptInvite } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
import {
|
||||
Button,
|
||||
ThemeProvider,
|
||||
TitleAndLogo,
|
||||
} from "./basicComponents/index.ts";
|
||||
import { PrettyAuthUI } from "./components/Auth.tsx";
|
||||
import { NewProjectForm } from "./3_NewProjectForm.tsx";
|
||||
import { ProjectTodoTable } from "./4_ProjectTodoTable.tsx";
|
||||
|
||||
/**
|
||||
* Walkthrough: The top-level provider `<WithJazz/>`
|
||||
*
|
||||
* This shows how to use the top-level provider `<WithJazz/>`,
|
||||
* which provides the rest of the app with a `LocalNode` (used through `useJazz` later),
|
||||
* based on `LocalAuth` that uses Passkeys (aka WebAuthn) to store a user's account secret
|
||||
* - no backend needed.
|
||||
*/
|
||||
|
||||
const appName = "Jazz Todo List Example";
|
||||
|
||||
const auth = LocalAuth({
|
||||
appName,
|
||||
Component: PrettyAuthUI,
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<WithJazz auth={auth}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
/**
|
||||
* Routing in `<App/>`
|
||||
*
|
||||
* <App> is the main app component, handling client-side routing based
|
||||
* on the CoValue ID (CoID) of our TodoProject, stored in the URL hash
|
||||
* - which can also contain invite links.
|
||||
*/
|
||||
|
||||
function App() {
|
||||
// logOut logs out the AuthProvider passed to `<WithJazz/>` above.
|
||||
const { logOut } = useJazz();
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <NewProjectForm />,
|
||||
},
|
||||
{
|
||||
path: "/project/:projectId",
|
||||
element: <ProjectTodoTable />,
|
||||
},
|
||||
{
|
||||
path: "/invite/*",
|
||||
element: <p>Accepting invite...</p>
|
||||
}
|
||||
]);
|
||||
|
||||
// `useAcceptInvite()` is a hook that accepts an invite link from the URL hash,
|
||||
// and on success calls our callback where we navigate to the project that we were just invited to.
|
||||
useAcceptInvite((projectID) => router.navigate("/project/" + projectID));
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
|
||||
<RouterProvider router={router} />
|
||||
|
||||
<Button
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: Continue with ./3_NewProjectForm.tsx */
|
||||
48
examples/todo/src/3_NewProjectForm.tsx
Normal file
48
examples/todo/src/3_NewProjectForm.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { Task, TodoProject } from "./1_types";
|
||||
|
||||
import { SubmittableInput } from "./basicComponents";
|
||||
|
||||
import { CoList } from "cojson";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export function NewProjectForm() {
|
||||
// A `LocalNode` represents a local view of loaded & created CoValues.
|
||||
// It is associated with a current user account, which will determine
|
||||
// access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
|
||||
const { localNode } = useJazz();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const createProject = useCallback(
|
||||
(title: string) => {
|
||||
if (!title) return;
|
||||
|
||||
// To create a new todo project, we first create a `Group`,
|
||||
// which is a scope for defining access rights (reader/writer/admin)
|
||||
// of its members, which will apply to all CoValues owned by that group.
|
||||
const projectGroup = localNode.createGroup();
|
||||
|
||||
// Then we create an empty todo project within that group
|
||||
const project = projectGroup.createMap<TodoProject>({
|
||||
title,
|
||||
tasks: projectGroup.createList<CoList<Task>>(),
|
||||
});
|
||||
|
||||
navigate("/project/" + project.id);
|
||||
},
|
||||
[localNode, navigate]
|
||||
);
|
||||
|
||||
return (
|
||||
<SubmittableInput
|
||||
onSubmit={createProject}
|
||||
label="Create New Project"
|
||||
placeholder="New project title"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: continue with ./4_ProjectTodoTable.tsx */
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { CoID } from "cojson";
|
||||
import { useTelepathicState } from "jazz-react";
|
||||
|
||||
import { TodoProject, Task } from "./1_types";
|
||||
|
||||
import {
|
||||
Checkbox,
|
||||
SubmittableInput,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "./basicComponents";
|
||||
|
||||
import { InviteButton } from "./components/InviteButton";
|
||||
import { NameBadge } from "./components/NameBadge";
|
||||
|
||||
/** Walkthrough: Reactively rendering a todo project as a table,
|
||||
* adding and editing tasks
|
||||
*
|
||||
* Here in `<TodoTable/>`, we use `useTelepathicData()` for the first time,
|
||||
* in this case to load the CoValue for our `TodoProject` as well as
|
||||
* the `ListOfTasks` referenced in it.
|
||||
*/
|
||||
|
||||
export function TodoTable({ projectId }: { projectId: CoID<TodoProject> }) {
|
||||
// `useTelepathicData()` reactively subscribes to updates to a CoValue's
|
||||
// content - whether we create edits locally, load persisted data, or receive
|
||||
// sync updates from other devices or participants!
|
||||
const project = useTelepathicState(projectId);
|
||||
const projectTasks = useTelepathicState(project?.get("tasks"));
|
||||
|
||||
// `createTask` is similar to `createProject` we saw earlier, creating a new CoMap
|
||||
// for a new task (in the same group as the list of tasks/the project), and then
|
||||
// adding it as an item to the project's list of tasks.
|
||||
const createTask = useCallback(
|
||||
(text: string) => {
|
||||
if (!projectTasks || !text) return;
|
||||
const task = projectTasks.group.createMap<Task>();
|
||||
|
||||
task.edit((task) => {
|
||||
task.set("text", text);
|
||||
task.set("done", false);
|
||||
});
|
||||
|
||||
projectTasks.edit((projectTasks) => {
|
||||
projectTasks.push(task.id);
|
||||
});
|
||||
},
|
||||
[projectTasks]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{
|
||||
// This is how we can access properties from the project,
|
||||
// accounting for the fact that it might not be loaded yet
|
||||
project?.get("title") ? (
|
||||
<>
|
||||
{project.get("title")}{" "}
|
||||
<span className="text-sm">({project.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)
|
||||
}
|
||||
</h1>
|
||||
<InviteButton list={project} />
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Done</TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{
|
||||
// Here, we iterate over the items of our `ListOfTasks`
|
||||
// and render a `<TaskRow>` for each.
|
||||
|
||||
projectTasks?.map((taskId: CoID<Task>) => (
|
||||
<TaskRow key={taskId} taskId={taskId} />
|
||||
))
|
||||
}
|
||||
<NewTaskInputRow
|
||||
createTask={createTask}
|
||||
disabled={!project}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
// `<TaskRow/>` uses `useTelepathicState()` as well, to granularly load and
|
||||
// subscribe to changes for that particular task.
|
||||
const task = useTelepathicState(taskId);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
className="mt-1"
|
||||
checked={task?.get("done")}
|
||||
onCheckedChange={(checked) => {
|
||||
// (the only thing we let the user change is the "done" status)
|
||||
task?.edit((task) => {
|
||||
task.set("done", !!checked);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
<span className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text") || (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</span>
|
||||
{/* We also use a `<NameBadge/>` helper component to render the name
|
||||
of the author of the task. We get the author by using the collaboration
|
||||
feature `whoEdited(key)` on our `Task` CoMap, which returns the accountID
|
||||
of the last account that changed a given key in the CoMap. */}
|
||||
<NameBadge accountID={task?.whoEdited("text")} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTaskInputRow({
|
||||
createTask,
|
||||
disabled,
|
||||
}: {
|
||||
createTask: (text: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox className="mt-1" disabled />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubmittableInput
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
177
examples/todo/src/4_ProjectTodoTable.tsx
Normal file
177
examples/todo/src/4_ProjectTodoTable.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { CoID, Queried } from "cojson";
|
||||
import { useSyncedQuery } from "jazz-react";
|
||||
|
||||
import { TodoProject, Task } from "./1_types";
|
||||
|
||||
import {
|
||||
Checkbox,
|
||||
SubmittableInput,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "./basicComponents";
|
||||
|
||||
import { InviteButton } from "./components/InviteButton";
|
||||
import uniqolor from "uniqolor";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
/** Walkthrough: Reactively rendering a todo project as a table,
|
||||
* adding and editing tasks
|
||||
*
|
||||
* Here in `<TodoTable/>`, we use `useSyncedQuery()` for the first time,
|
||||
* in this case to load the CoValue for our `TodoProject` as well as
|
||||
* the `ListOfTasks` referenced in it.
|
||||
*/
|
||||
|
||||
export function ProjectTodoTable() {
|
||||
const projectId = useParams<{ projectId: CoID<TodoProject> }>().projectId;
|
||||
|
||||
// `useSyncedQuery()` reactively subscribes to updates to a CoValue's
|
||||
// content - whether we create edits locally, load persisted data, or receive
|
||||
// sync updates from other devices or participants!
|
||||
// It also recursively resolves and subsribes to all referenced CoValues.
|
||||
const project = useSyncedQuery(projectId);
|
||||
|
||||
// `createTask` is similar to `createProject` we saw earlier, creating a new CoMap
|
||||
// for a new task (in the same group as the project), and then
|
||||
// adding that as an item to the project's list of tasks.
|
||||
const createTask = useCallback(
|
||||
(text: string) => {
|
||||
if (!project?.tasks || !text) return;
|
||||
const task = project.group.createMap<Task>({
|
||||
done: false,
|
||||
text,
|
||||
});
|
||||
|
||||
// project.tasks is immutable, but `append` will create an edit
|
||||
// that will cause useSyncedQuery to rerender this component
|
||||
// - here and on other devices!
|
||||
project.tasks.append(task);
|
||||
},
|
||||
[project?.tasks, project?.group]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{
|
||||
// This is how we can access properties from the project query,
|
||||
// accounting for the fact that note everything might be loaded yet
|
||||
project?.title ? (
|
||||
<>
|
||||
{project.title}{" "}
|
||||
<span className="text-sm">({project.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)
|
||||
}
|
||||
</h1>
|
||||
<InviteButton value={project} />
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Done</TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{project?.tasks?.map(
|
||||
(task) => task && <TaskRow key={task.id} task={task} />
|
||||
)}
|
||||
<NewTaskInputRow
|
||||
createTask={createTask}
|
||||
disabled={!project}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRow({ task }: { task: Queried<Task> | undefined }) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
className="mt-1"
|
||||
checked={task?.done}
|
||||
onCheckedChange={(checked) => {
|
||||
// Tick or untick the task
|
||||
// Task is also immutable, but this will update all queries
|
||||
// that include this task as a reference
|
||||
task?.set({ done: !!checked });
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
{task?.text ? (
|
||||
<span className={task?.done ? "line-through" : ""}>
|
||||
{task.text}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
{
|
||||
// Here we see for the first time how we can access edit history
|
||||
// for a CoValue, and use it to display who created the task.
|
||||
task?.edits.text?.by?.profile?.name ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={uniqueColoring(task.edits.text.by.id)}
|
||||
>
|
||||
{task.edits.text.by.profile.name}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: This is the end of the walkthrough so far! */
|
||||
|
||||
function NewTaskInputRow({
|
||||
createTask,
|
||||
disabled,
|
||||
}: {
|
||||
createTask: (text: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox className="mt-1" disabled />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubmittableInput
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueColoring(seed: string) {
|
||||
const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
return {
|
||||
color: uniqolor(seed, { lightness: darkMode ? 80 : 20 }).color,
|
||||
background: uniqolor(seed, { lightness: darkMode ? 20 : 80 }).color,
|
||||
};
|
||||
}
|
||||
@@ -1,27 +1,26 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { TodoProject } from "../1_types";
|
||||
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import { useToast, Button } from "../basicComponents";
|
||||
import { CoValue, Queried } from "cojson";
|
||||
|
||||
export function InviteButton({ list }: { list?: TodoProject }) {
|
||||
export function InviteButton<T extends CoValue>({ value }: { value: T | Queried<T> | undefined }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
list?.group.myRole() === "admin" && (
|
||||
value?.group?.myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
disabled={!value.group || !value.id}
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (list && !inviteLink) {
|
||||
inviteLink = createInviteLink(list, "writer");
|
||||
if (value.group && value.id && !inviteLink) {
|
||||
inviteLink = createInviteLink(value, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { AccountID } from "cojson";
|
||||
import { useProfile } from "jazz-react";
|
||||
|
||||
import { Skeleton } from "@/basicComponents";
|
||||
import uniqolor from "uniqolor";
|
||||
|
||||
/** Walkthrough: Getting user profiles in `<NameBadge/>`
|
||||
*
|
||||
* `<NameBadge/>` uses `useProfile(accountID)`, which is a shorthand for
|
||||
* useTelepathicState on an account's profile.
|
||||
*
|
||||
* Profiles are always a `CoMap<{name: string}>`, but they might have app-specific
|
||||
* additional properties).
|
||||
*
|
||||
* In our case, we just display the profile name (which is set by the LocalAuth
|
||||
* provider when we first create an account).
|
||||
*/
|
||||
|
||||
export function NameBadge({ accountID }: { accountID?: AccountID }) {
|
||||
const profile = useProfile(accountID);
|
||||
|
||||
return accountID && profile?.get("name") ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={randomUserColor(accountID)}
|
||||
>
|
||||
{profile.get("name")}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
|
||||
);
|
||||
}
|
||||
|
||||
function randomUserColor(accountID: AccountID) {
|
||||
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
|
||||
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
|
||||
|
||||
return {
|
||||
color: theme == "light" ? darkColor : brightColor,
|
||||
background: theme == "light" ? brightColor : darkColor,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CoID, LocalNode, CoValueImpl } from "cojson";
|
||||
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
|
||||
|
||||
export function useSimpleHashRouterThatAcceptsInvites<C extends CoValueImpl>(
|
||||
localNode: LocalNode
|
||||
) {
|
||||
const [currentValueId, setCurrentValueId] = useState<CoID<C>>();
|
||||
|
||||
useEffect(() => {
|
||||
const listener = async () => {
|
||||
const acceptedInvitation = await consumeInviteLinkFromWindowLocation<C>(localNode);
|
||||
|
||||
if (acceptedInvitation) {
|
||||
setCurrentValueId(acceptedInvitation.valueID);
|
||||
window.location.hash = acceptedInvitation.valueID;
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentValueId(
|
||||
(window.location.hash.slice(1) as CoID<C>) || undefined
|
||||
);
|
||||
};
|
||||
window.addEventListener("hashchange", listener);
|
||||
listener();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", listener);
|
||||
};
|
||||
}, [localNode]);
|
||||
|
||||
const navigateToValue = useCallback((id: CoID<C> | undefined) => {
|
||||
window.location.hash = id || "";
|
||||
}, []);
|
||||
|
||||
return [currentValueId, navigateToValue] as const;
|
||||
}
|
||||
616
generateDocs.ts
616
generateDocs.ts
@@ -1,5 +1,5 @@
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { Application, JSONOutput } from "typedoc";
|
||||
import { Application, JSONOutput, ReflectionKind } from "typedoc";
|
||||
|
||||
const manuallyIgnore = new Set(["CojsonInternalTypes"]);
|
||||
|
||||
@@ -8,14 +8,18 @@ async function main() {
|
||||
// Also accepts an array of option readers if you want to disable
|
||||
// TypeDoc's tsconfig.json/package.json/typedoc.json option readers
|
||||
const packageDocs = Object.entries({
|
||||
cojson: "index.ts",
|
||||
"jazz-react": "index.tsx",
|
||||
cojson: "index.ts",
|
||||
"jazz-react-media-images": "index.tsx",
|
||||
"jazz-browser": "index.ts",
|
||||
"jazz-browser-media-images": "index.ts",
|
||||
}).map(async ([packageName, entryPoint]) => {
|
||||
const app = await Application.bootstrapWithPlugins({
|
||||
entryPoints: [`packages/${packageName}/src/${entryPoint}`],
|
||||
tsconfig: `packages/${packageName}/tsconfig.json`,
|
||||
sort: ["required-first"],
|
||||
groupOrder: ["Functions", "Classes", "TypeAliases", "Namespaces"],
|
||||
categorizeByGroup: false
|
||||
});
|
||||
|
||||
const project = await app.convert();
|
||||
@@ -35,33 +39,67 @@ async function main() {
|
||||
docs
|
||||
.groups!.map((group) => {
|
||||
return group.children
|
||||
?.map((childId) => {
|
||||
?.flatMap((childId) => {
|
||||
const child = docs.children!.find(
|
||||
(child) => child.id === childId
|
||||
)!;
|
||||
|
||||
if (manuallyIgnore.has(child.name)) {
|
||||
return "";
|
||||
if (
|
||||
manuallyIgnore.has(child.name) ||
|
||||
child.comment?.blockTags?.some(
|
||||
(tag) =>
|
||||
tag.tag === "@deprecated" ||
|
||||
tag.tag === "@internal" ||
|
||||
tag.tag === "@ignore"
|
||||
) ||
|
||||
child.signatures?.every((signature) =>
|
||||
signature.comment?.blockTags?.some(
|
||||
(tag) =>
|
||||
tag.tag === "@deprecated" ||
|
||||
tag.tag === "@internal" ||
|
||||
tag.tag === "@ignore"
|
||||
)
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
`## \`${renderChildName(child)}\` (${group.title
|
||||
`## \`${renderChildName(
|
||||
child
|
||||
)}\`\n\n<sup>(${group.title
|
||||
.toLowerCase()
|
||||
.replace("bles", "ble")
|
||||
.replace("ces", "ce")
|
||||
.replace(/es$/, "")
|
||||
.replace(
|
||||
"ns",
|
||||
"n"
|
||||
)} in \`${packageName}\`)\n\n` +
|
||||
)} in \`${packageName}\`)</sup>\n\n` +
|
||||
renderChildType(child) +
|
||||
renderComment(child.comment) +
|
||||
(child.kind === 128 || child.kind === 256
|
||||
? child.groups
|
||||
?.map((group) =>
|
||||
renderChildGroup(child, group)
|
||||
(child.kind === ReflectionKind.Class ||
|
||||
child.kind === ReflectionKind.Interface ||
|
||||
child.kind === ReflectionKind.Namespace
|
||||
? renderSummary(child.comment) +
|
||||
renderExamples(child.comment) +
|
||||
(child.categories || child.groups)
|
||||
?.map((category) =>
|
||||
renderChildCategory(child, category)
|
||||
)
|
||||
.join("\n\n")
|
||||
: "TODO: doc generator not implemented yet")
|
||||
.join("<br/>\n\n")
|
||||
: child.kind === ReflectionKind.Function
|
||||
? renderSummary(
|
||||
child.signatures?.[0].comment
|
||||
) +
|
||||
renderParamComments(
|
||||
child.signatures?.[0].parameters || []
|
||||
) +
|
||||
renderExamples(
|
||||
child.signatures?.[0].comment
|
||||
) +
|
||||
"\n\n"
|
||||
: "TODO: doc generator not implemented yet " +
|
||||
child.kind)
|
||||
);
|
||||
})
|
||||
.join("\n\n----\n\n");
|
||||
@@ -69,7 +107,7 @@ async function main() {
|
||||
.join("\n\n----\n\n")
|
||||
);
|
||||
|
||||
function renderComment(comment?: JSONOutput.Comment): string {
|
||||
function renderSummary(comment?: JSONOutput.Comment): string {
|
||||
if (comment) {
|
||||
return (
|
||||
comment.summary
|
||||
@@ -80,11 +118,50 @@ async function main() {
|
||||
)
|
||||
.join("") +
|
||||
"\n\n" +
|
||||
(comment.blockTags || [])
|
||||
.map((blockTag) =>
|
||||
blockTag.tag === "@example"
|
||||
? "##### Example:\n\n" +
|
||||
blockTag.content
|
||||
"\n\n"
|
||||
);
|
||||
} else {
|
||||
return "TODO: document\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
function renderExamples(comment?: JSONOutput.Comment): string {
|
||||
return (comment?.blockTags || [])
|
||||
.map((blockTag) =>
|
||||
blockTag.tag === "@example"
|
||||
? "##### Example:\n\n" +
|
||||
blockTag.content
|
||||
.map((token) =>
|
||||
token.kind === "text" || token.kind === "code"
|
||||
? token.text
|
||||
: ""
|
||||
)
|
||||
.join("") +
|
||||
"\n\n"
|
||||
: ""
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderParamComments(params: JSONOutput.ParameterReflection[]) {
|
||||
const paramDocs = params.flatMap((param) => {
|
||||
if (param.type?.type === "reflection") {
|
||||
return param.type.declaration.children?.flatMap((child) => {
|
||||
if (
|
||||
child.name === "children" &&
|
||||
child.type?.type === "reference" &&
|
||||
child.type?.name === "ReactNode"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return (
|
||||
`| \`${param.name}.${child.name}${
|
||||
child.flags.isOptional || child.defaultValue
|
||||
? "?"
|
||||
: ""
|
||||
}\` | ` +
|
||||
(child.comment
|
||||
? child.comment.summary
|
||||
.map((token) =>
|
||||
token.kind === "text" ||
|
||||
token.kind === "code"
|
||||
@@ -92,13 +169,37 @@ async function main() {
|
||||
: ""
|
||||
)
|
||||
.join("")
|
||||
: "TODO: document") +
|
||||
" |"
|
||||
);
|
||||
});
|
||||
} else {
|
||||
const comment = param.comment;
|
||||
return [
|
||||
`| \`${param.name}${
|
||||
param.flags.isOptional || param.defaultValue
|
||||
? "?"
|
||||
: ""
|
||||
)
|
||||
.join("\n\n") +
|
||||
"\n\n"
|
||||
);
|
||||
} else {
|
||||
return "TODO: document\n\n";
|
||||
}\` | ` +
|
||||
(comment
|
||||
? comment.summary
|
||||
.map((token) =>
|
||||
token.kind === "text" ||
|
||||
token.kind === "code"
|
||||
? token.text
|
||||
: ""
|
||||
)
|
||||
.join("")
|
||||
: "TODO: document ") +
|
||||
" |",
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
if (paramDocs.length) {
|
||||
return `### Parameters:\n\n| name | description |\n| ----: | ---- |\n${paramDocs.join(
|
||||
"\n"
|
||||
)}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,26 +227,30 @@ async function main() {
|
||||
function renderChildType(
|
||||
child: JSONOutput.DeclarationReflection
|
||||
): string {
|
||||
const isClass = child.kind === 128;
|
||||
const isTypeDef = child.kind === 2097152;
|
||||
const isInterface = child.kind === 256;
|
||||
const isClass = child.kind === ReflectionKind.Class;
|
||||
const isTypeAlias = child.kind === ReflectionKind.TypeAlias;
|
||||
const isInterface = child.kind === ReflectionKind.Interface;
|
||||
const isNamespace = child.kind === ReflectionKind.Namespace;
|
||||
const isFunction = !!child.signatures;
|
||||
|
||||
const kind = isClass
|
||||
? "class"
|
||||
: isTypeAlias
|
||||
? "type"
|
||||
: isFunction
|
||||
? "function"
|
||||
: isInterface
|
||||
? "interface"
|
||||
: isNamespace
|
||||
? "namespace"
|
||||
: "";
|
||||
|
||||
return (
|
||||
"```typescript\n" +
|
||||
`export ${
|
||||
isClass
|
||||
? "class"
|
||||
: isTypeDef
|
||||
? "type"
|
||||
: isFunction
|
||||
? "function"
|
||||
: isInterface
|
||||
? "interface"
|
||||
: ""
|
||||
} ${child.name}` +
|
||||
(child.typeParameters
|
||||
`export ${kind} ${child.name}` +
|
||||
((child.typeParameters || child.signatures?.[0].typeParameter)
|
||||
? "<" +
|
||||
child.typeParameters.map(renderTypeParam).join(", ") +
|
||||
(child.typeParameters || child.signatures?.[0].typeParameter || []).map(renderTypeParam).join(", ") +
|
||||
">"
|
||||
: "") +
|
||||
(child.extendedTypes
|
||||
@@ -156,9 +261,9 @@ async function main() {
|
||||
? " implements " +
|
||||
child.implementedTypes.map(renderType).join(", ")
|
||||
: "") +
|
||||
(isClass || isInterface
|
||||
(isClass || isInterface || isNamespace
|
||||
? " {...}"
|
||||
: isTypeDef
|
||||
: isTypeAlias
|
||||
? ` = ${renderType(child.type)}`
|
||||
: child.signatures
|
||||
? `(${(child.signatures[0].parameters || [])
|
||||
@@ -171,33 +276,69 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
function renderChildGroup(
|
||||
function renderChildCategory(
|
||||
child: JSONOutput.DeclarationReflection,
|
||||
group: JSONOutput.ReflectionGroup
|
||||
category: JSONOutput.ReflectionGroup
|
||||
): string {
|
||||
return (
|
||||
`### ${group.title}\n\n` +
|
||||
group.children
|
||||
`### \`${child.name}\`: ${category.title.replace(/[^d]+\./, "")}\n\n` +
|
||||
category.children
|
||||
?.map((memberId) => {
|
||||
const member = child.children!.find(
|
||||
(member) => member.id === memberId
|
||||
)!;
|
||||
|
||||
if (member.kind === 2048 || member.kind === 512) {
|
||||
if (member.signatures?.every(sig => sig.comment?.modifierTags?.includes("@internal"))) {
|
||||
return ""
|
||||
if (
|
||||
member.signatures?.every(
|
||||
(sig) =>
|
||||
sig.comment?.modifierTags?.includes(
|
||||
"@internal"
|
||||
) ||
|
||||
sig.comment?.modifierTags?.includes(
|
||||
"@deprecated"
|
||||
)
|
||||
)
|
||||
) {
|
||||
return "";
|
||||
} else {
|
||||
return documentConstructorOrMethod(member, child);
|
||||
return documentConstructorOrMethod(
|
||||
member,
|
||||
child
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
member.kind === 1024 ||
|
||||
member.kind === 262144
|
||||
) {
|
||||
if (member.comment?.modifierTags?.includes("@internal")) {
|
||||
return ""
|
||||
if (
|
||||
member.comment?.modifierTags?.includes(
|
||||
"@internal"
|
||||
) ||
|
||||
member.comment?.modifierTags?.includes(
|
||||
"@deprecated"
|
||||
)
|
||||
) {
|
||||
return "";
|
||||
} else {
|
||||
return documentProperty(member, child);
|
||||
}
|
||||
} else if (member.kind === 2097152) {
|
||||
if (
|
||||
member.comment?.modifierTags?.includes(
|
||||
"@internal"
|
||||
) ||
|
||||
member.comment?.modifierTags?.includes(
|
||||
"@deprecated"
|
||||
)
|
||||
) {
|
||||
return "";
|
||||
} else {
|
||||
return documentProperty(
|
||||
{ ...member, flags: { isStatic: true } },
|
||||
child
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return "Unknown member kind " + member.kind;
|
||||
}
|
||||
@@ -220,9 +361,39 @@ async function main() {
|
||||
} else if (t.type === "literal") {
|
||||
return JSON.stringify(t.value);
|
||||
} else if (t.type === "union") {
|
||||
return [...new Set(t.types.map(renderType))].join(" | ");
|
||||
const seen = new Set<string>();
|
||||
return t.types
|
||||
.flatMap((t) => {
|
||||
const rendered =
|
||||
t.type === "intersection" || t.type === "union"
|
||||
? `(${renderType(t)})`
|
||||
: renderType(t);
|
||||
|
||||
if (seen.has(rendered)) {
|
||||
return [];
|
||||
} else {
|
||||
seen.add(rendered);
|
||||
return [rendered];
|
||||
}
|
||||
})
|
||||
.join(" | ");
|
||||
} else if (t.type === "intersection") {
|
||||
return [...new Set(t.types.map(renderType))].join(" & ");
|
||||
const seen = new Set<string>();
|
||||
return t.types
|
||||
.flatMap((t) => {
|
||||
const rendered =
|
||||
t.type === "intersection" || t.type === "union"
|
||||
? `(${renderType(t)})`
|
||||
: renderType(t);
|
||||
|
||||
if (seen.has(rendered)) {
|
||||
return [];
|
||||
} else {
|
||||
seen.add(rendered);
|
||||
return [rendered];
|
||||
}
|
||||
})
|
||||
.join(" & ");
|
||||
} else if (t.type === "indexedAccess") {
|
||||
return (
|
||||
renderType(t.objectType) +
|
||||
@@ -233,40 +404,84 @@ async function main() {
|
||||
} else if (t.type === "reflection") {
|
||||
if (t.declaration.indexSignature) {
|
||||
return (
|
||||
"{ [" +
|
||||
`{${
|
||||
t.declaration.children
|
||||
? t.declaration.children
|
||||
.map(
|
||||
(child) =>
|
||||
` ${child.name}${
|
||||
child.flags.isOptional
|
||||
? "?"
|
||||
: ""
|
||||
}: ${indentEnd(
|
||||
renderType(child.type)
|
||||
)},`
|
||||
)
|
||||
.join("\n")
|
||||
: ""
|
||||
}\n [` +
|
||||
t.declaration.indexSignature?.parameters?.[0].name +
|
||||
": " +
|
||||
renderType(
|
||||
t.declaration.indexSignature?.parameters?.[0].type
|
||||
) +
|
||||
"]: " +
|
||||
renderType(t.declaration.indexSignature?.type) +
|
||||
indentEnd(
|
||||
renderType(t.declaration.indexSignature?.type)
|
||||
) +
|
||||
" }"
|
||||
);
|
||||
} else if (t.declaration.children) {
|
||||
return `{${t.declaration.children
|
||||
.map(
|
||||
(child) =>
|
||||
`${child.name}${
|
||||
child.flags.isOptional ? "?" : ""
|
||||
}: ${renderType(child.type)}`
|
||||
return `{\n${t.declaration.children
|
||||
.map((child) =>
|
||||
child.signatures
|
||||
? child.signatures
|
||||
.map(
|
||||
(signature) =>
|
||||
` ${child.name}(${
|
||||
signature.parameters
|
||||
? "\n " +
|
||||
indent(
|
||||
signature.parameters
|
||||
.map((p) =>
|
||||
indentEnd(
|
||||
renderParam(
|
||||
p
|
||||
)
|
||||
)
|
||||
)
|
||||
.join(",\n ")
|
||||
) +
|
||||
"\n )"
|
||||
: "()"
|
||||
}: ${indentEnd(
|
||||
renderType(signature.type)
|
||||
)}`
|
||||
)
|
||||
.join("\n") + ",\n"
|
||||
: ` ${child.name}${
|
||||
child.flags.isOptional ? "?" : ""
|
||||
}: ${indentEnd(renderType(child.type))},\n`
|
||||
)
|
||||
.join(", ")}}`;
|
||||
.join("")}}`;
|
||||
} else if (t.declaration.signatures) {
|
||||
if (t.declaration.signatures.length > 1) {
|
||||
return "COMPLEX_TYPE_MULTIPLE_INLINE_SIGNATURES";
|
||||
} else {
|
||||
return `(${(
|
||||
t.declaration.signatures[0].parameters || []
|
||||
).map(renderParam)}) => ${renderType(
|
||||
t.declaration.signatures[0].type
|
||||
)}`;
|
||||
}
|
||||
return t.declaration.signatures
|
||||
.map(
|
||||
(signature) =>
|
||||
`(${(signature.parameters || [])
|
||||
.map(renderParam)
|
||||
.join(", ")}) => ${renderType(
|
||||
signature.type
|
||||
)}`
|
||||
)
|
||||
.join("\n");
|
||||
} else {
|
||||
return "COMPLEX_TYPE_REFLECTION";
|
||||
}
|
||||
} else if (t.type === "array") {
|
||||
return renderType(t.elementType) + "[]";
|
||||
} else if (t.type === "tuple") {
|
||||
return `[${t.elements?.map(renderType).join(", ")}]`;
|
||||
} else if (t.type === "templateLiteral") {
|
||||
const matchingNamedType = docs.children?.find(
|
||||
(child) =>
|
||||
@@ -296,9 +511,55 @@ async function main() {
|
||||
return "AgentID";
|
||||
}
|
||||
} else {
|
||||
return "TEMPLATE_LITERAL";
|
||||
return (
|
||||
"`" +
|
||||
t.head +
|
||||
t.tail
|
||||
.map(
|
||||
(bit) =>
|
||||
"${" + renderType(bit[0]) + "}" + bit[1]
|
||||
)
|
||||
.join("") +
|
||||
"`"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (t.type === "conditional") {
|
||||
const trueRendered = renderType(t.trueType);
|
||||
const falseRendered = renderType(t.falseType);
|
||||
|
||||
if (
|
||||
trueRendered.includes("\n") ||
|
||||
falseRendered.includes("\n")
|
||||
) {
|
||||
return (
|
||||
renderType(t.checkType) +
|
||||
" extends " +
|
||||
renderType(t.extendsType) +
|
||||
"\n ? " +
|
||||
indentEnd(renderType(t.trueType)) +
|
||||
"\n : " +
|
||||
indentEnd(renderType(t.falseType))
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
renderType(t.checkType) +
|
||||
" extends " +
|
||||
renderType(t.extendsType) +
|
||||
" ? " +
|
||||
renderType(t.trueType) +
|
||||
" : " +
|
||||
renderType(t.falseType)
|
||||
);
|
||||
}
|
||||
} else if (t.type === "inferred") {
|
||||
return "infer " + t.name;
|
||||
} else if (t.type === "typeOperator") {
|
||||
return t.operator + " " + renderType(t.target);
|
||||
} else if (t.type === "mapped") {
|
||||
return `{\n [${t.parameter} in ${renderType(
|
||||
t.parameterType
|
||||
)}]: ${indentEnd(renderType(t.templateType))}\n}`;
|
||||
} else {
|
||||
return "COMPLEX_TYPE_" + t.type;
|
||||
}
|
||||
@@ -341,101 +602,186 @@ async function main() {
|
||||
(child) =>
|
||||
child.name + (child.flags.isOptional ? "?" : "")
|
||||
)
|
||||
.join(", ")}}${param.defaultValue ? "?" : ""}`
|
||||
: param.name + (param.defaultValue ? "?" : "");
|
||||
.join(", ")}}${
|
||||
param.flags.isOptional || param.defaultValue ? "?" : ""
|
||||
}`
|
||||
: param.name +
|
||||
(param.flags.isOptional || param.defaultValue ? "?" : "");
|
||||
}
|
||||
|
||||
function documentConstructorOrMethod(
|
||||
member: JSONOutput.DeclarationReflection,
|
||||
child: JSONOutput.DeclarationReflection
|
||||
) {
|
||||
const isInClass = child.kind === 128;
|
||||
const isInTypeDef = child.kind === 2097152;
|
||||
const isInInterface = child.kind === 256;
|
||||
const isInNamespace = child.kind === 4;
|
||||
const isInFunction = !!child.signatures;
|
||||
|
||||
const inKind = isInClass
|
||||
? "class"
|
||||
: isInTypeDef
|
||||
? "type"
|
||||
: isInFunction
|
||||
? "function"
|
||||
: isInInterface
|
||||
? "interface"
|
||||
: isInNamespace
|
||||
? "namespace"
|
||||
: "";
|
||||
|
||||
const stem =
|
||||
member.name === "constructor"
|
||||
? "new " + child.name
|
||||
: (member.flags.isStatic
|
||||
? child.name
|
||||
: child.name[0].toLowerCase() + child.name.slice(1)) +
|
||||
? "new " + child.name + "</code></b>"
|
||||
: (member.flags.isStatic ? child.name : "") +
|
||||
"." +
|
||||
member.name;
|
||||
member.name +
|
||||
"";
|
||||
|
||||
return (
|
||||
`<details>\n<summary><code>${stem}(${(
|
||||
member.signatures?.[0]?.parameters?.map(
|
||||
renderParamSimple
|
||||
) || []
|
||||
).join(", ")})</code> ${
|
||||
member.inheritedFrom
|
||||
? "(from <code>" +
|
||||
member.inheritedFrom.name.split(".")[0] +
|
||||
"</code>) "
|
||||
: ""
|
||||
} ${
|
||||
member.signatures?.[0]?.comment ? "" : "(undocumented)"
|
||||
}</summary>\n\n` +
|
||||
member.signatures?.map((signature) => {
|
||||
return member.signatures
|
||||
?.map((signature) => {
|
||||
return (
|
||||
"```typescript\n" +
|
||||
`${stem}${
|
||||
signature.typeParameter
|
||||
? `<${signature.typeParameter
|
||||
.map(renderTypeParam)
|
||||
.join(", ")}>`
|
||||
`<details>\n<summary><b><code>${stem}(${(
|
||||
signature?.parameters?.map(renderParamSimple) || []
|
||||
).join(", ")})</code></b> ${
|
||||
member.inheritedFrom
|
||||
? "<sub><sup>from <code>" +
|
||||
member.inheritedFrom.name.split(".")[0] +
|
||||
"</code></sup></sub> "
|
||||
: ""
|
||||
}(${
|
||||
(
|
||||
signature.parameters?.map(
|
||||
(param) =>
|
||||
`\n ${param.name}${
|
||||
param.defaultValue ? "?" : ""
|
||||
}: ${renderType(param.type)}${
|
||||
param.defaultValue
|
||||
? ` = ${param.defaultValue}`
|
||||
: ""
|
||||
}`
|
||||
) || []
|
||||
).join(",") +
|
||||
(signature.parameters?.length ? "\n" : "")
|
||||
}): ${renderType(signature.type)}\n` +
|
||||
"```\n" +
|
||||
renderComment(signature.comment)
|
||||
} ${
|
||||
signature?.comment
|
||||
? ""
|
||||
: "<sub><sup>(undocumented)</sup></sub>"
|
||||
}</summary>\n\n` +
|
||||
("```typescript\n" +
|
||||
`${inKind} ${child.name}${
|
||||
child.typeParameters
|
||||
? `<${child.typeParameters
|
||||
.map((t) => t.name)
|
||||
.join(", ")}>`
|
||||
: ""
|
||||
} {\n\n${indent(
|
||||
`${member.name}${
|
||||
signature.typeParameter
|
||||
? `<${signature.typeParameter
|
||||
.map(renderTypeParam)
|
||||
.join(", ")}>`
|
||||
: ""
|
||||
}(${
|
||||
(
|
||||
signature.parameters?.map(
|
||||
(param) =>
|
||||
`\n ${param.name}${
|
||||
param.flags.isOptional ||
|
||||
param.defaultValue
|
||||
? "?"
|
||||
: ""
|
||||
}: ${indentEnd(
|
||||
renderType(param.type)
|
||||
)}${
|
||||
param.defaultValue
|
||||
? ` = ${param.defaultValue}`
|
||||
: ""
|
||||
}`
|
||||
) || []
|
||||
).join(",") +
|
||||
(signature.parameters?.length ? "\n" : "")
|
||||
}): ${renderType(signature.type)} {...}`
|
||||
)}\n\n}\n` +
|
||||
"```\n" +
|
||||
renderSummary(signature.comment)) +
|
||||
renderParamComments(signature.parameters || []) +
|
||||
renderExamples(signature.comment) +
|
||||
"</details>\n\n"
|
||||
);
|
||||
}) +
|
||||
"</details>\n\n"
|
||||
);
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function documentProperty(
|
||||
member: JSONOutput.DeclarationReflection,
|
||||
child: JSONOutput.DeclarationReflection
|
||||
) {
|
||||
const stem = member.flags.isStatic
|
||||
? child.name
|
||||
: child.name[0].toLowerCase() + child.name.slice(1);
|
||||
const isInClass = child.kind === 128;
|
||||
const isInTypeDef = child.kind === 2097152;
|
||||
const isInInterface = child.kind === 256;
|
||||
const isInNamespace = child.kind === 4;
|
||||
const isInFunction = !!child.signatures;
|
||||
|
||||
const inKind = isInClass
|
||||
? "class"
|
||||
: isInTypeDef
|
||||
? "type"
|
||||
: isInFunction
|
||||
? "function"
|
||||
: isInInterface
|
||||
? "interface"
|
||||
: isInNamespace
|
||||
? "namespace"
|
||||
: "";
|
||||
|
||||
const stem = member.flags.isStatic ? child.name : "";
|
||||
return (
|
||||
`<details>\n<summary><code>${stem}.${member.name}</code> ${
|
||||
`<details>\n<summary><b><code>${stem}.${
|
||||
member.name
|
||||
}</code></b> ${
|
||||
member.inheritedFrom
|
||||
? "(from <code>" +
|
||||
? "<sub><sup>from <code>" +
|
||||
member.inheritedFrom.name.split(".")[0] +
|
||||
"</code>) "
|
||||
"</code></sup></sub> "
|
||||
: ""
|
||||
} ${
|
||||
member.comment ? "" : "(undocumented)"
|
||||
member.comment ? "" : "<sub><sup>(undocumented)</sup></sub>"
|
||||
}</summary>\n\n` +
|
||||
"```typescript\n" +
|
||||
`${member.getSignature ? "get " : ""}${stem}.${member.name}${
|
||||
member.getSignature ? "()" : ""
|
||||
}: ${renderType(member.type || member.getSignature?.type)}\n` +
|
||||
"```\n" +
|
||||
renderComment(member.comment) +
|
||||
`${inKind} ${child.name}${
|
||||
child.typeParameters
|
||||
? `<${child.typeParameters
|
||||
.map((t) => t.name)
|
||||
.join(", ")}>`
|
||||
: ""
|
||||
} {\n\n${indent(
|
||||
`${member.getSignature ? "get " : ""}${member.name}${
|
||||
member.getSignature ? "()" : ""
|
||||
}: ${renderType(member.type || member.getSignature?.type)}${
|
||||
member.getSignature ? " {...}" : ""
|
||||
}`
|
||||
)}` +
|
||||
"\n\n}\n```\n" +
|
||||
renderSummary(member.comment) +
|
||||
renderExamples(member.comment) +
|
||||
"</details>\n\n"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const docsContent = await readFile("./DOCS.md", "utf8");
|
||||
|
||||
await writeFile(
|
||||
"./DOCS.md",
|
||||
(await Promise.all(packageDocs)).join("\n\n\n")
|
||||
docsContent.slice(
|
||||
0,
|
||||
docsContent.indexOf("<!-- AUTOGENERATED DOCS AFTER THIS POINT -->")
|
||||
) +
|
||||
"<!-- AUTOGENERATED DOCS AFTER THIS POINT -->\n" +
|
||||
(await Promise.all(packageDocs)).join("\n\n\n")
|
||||
);
|
||||
}
|
||||
|
||||
function indent(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => " " + line)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function indentEnd(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : " " + line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.1.13",
|
||||
"version": "0.3.6",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/ws": "^8.5.5",
|
||||
@@ -16,8 +16,8 @@
|
||||
"typescript": "5.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.12",
|
||||
"cojson-storage-sqlite": "^0.1.10",
|
||||
"cojson": "^0.3.4",
|
||||
"cojson-storage-sqlite": "^0.3.6",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -16,7 +16,7 @@ const localNode = new LocalNode(
|
||||
);
|
||||
|
||||
SQLiteStorage.asPeer({ filename: "./sync.db" })
|
||||
.then((storage) => localNode.sync.addPeer(storage))
|
||||
.then((storage) => localNode.syncManager.addPeer(storage))
|
||||
.catch((e) => console.error(e));
|
||||
|
||||
wss.on("connection", function connection(ws, req) {
|
||||
@@ -34,8 +34,6 @@ wss.on("connection", function connection(ws, req) {
|
||||
clearInterval(pinging);
|
||||
});
|
||||
|
||||
|
||||
|
||||
const clientAddress =
|
||||
(req.headers["x-forwarded-for"] as string | undefined)
|
||||
?.split(",")[0]
|
||||
@@ -43,7 +41,7 @@ wss.on("connection", function connection(ws, req) {
|
||||
|
||||
const clientId = clientAddress + "@" + new Date().toISOString();
|
||||
|
||||
localNode.sync.addPeer({
|
||||
localNode.syncManager.addPeer({
|
||||
id: clientId,
|
||||
role: "client",
|
||||
incoming: websocketReadableStream(ws),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-storage-indexeddb",
|
||||
"version": "0.1.12",
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.3.4",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.12",
|
||||
"cojson": "^0.3.4",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -12,7 +12,7 @@ test.skip("Should be able to initialize and load from empty DB", async () => {
|
||||
)
|
||||
);
|
||||
|
||||
node.sync.addPeer(await IDBStorage.asPeer({ trace: true }));
|
||||
node.syncManager.addPeer(await IDBStorage.asPeer({ trace: true }));
|
||||
|
||||
console.log("yay!");
|
||||
|
||||
@@ -20,7 +20,7 @@ test.skip("Should be able to initialize and load from empty DB", async () => {
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(node.sync.peers["storage"]).toBeDefined();
|
||||
expect(node.syncManager.peers["storage"]).toBeDefined();
|
||||
});
|
||||
|
||||
test("Should be able to sync data to database and then load that from a new node", async () => {
|
||||
@@ -33,7 +33,7 @@ test("Should be able to sync data to database and then load that from a new node
|
||||
)
|
||||
);
|
||||
|
||||
node1.sync.addPeer(
|
||||
node1.syncManager.addPeer(
|
||||
await IDBStorage.asPeer({ trace: true, localNodeName: "node1" })
|
||||
);
|
||||
|
||||
@@ -56,7 +56,7 @@ test("Should be able to sync data to database and then load that from a new node
|
||||
)
|
||||
);
|
||||
|
||||
node2.sync.addPeer(
|
||||
node2.syncManager.addPeer(
|
||||
await IDBStorage.asPeer({ trace: true, localNodeName: "node2" })
|
||||
);
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { cojsonInternals, SessionID, SyncMessage, Peer, CojsonInternalTypes } from "cojson";
|
||||
import {
|
||||
cojsonInternals,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
Peer,
|
||||
CojsonInternalTypes,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
} from "cojson";
|
||||
import {
|
||||
ReadableStream,
|
||||
WritableStream,
|
||||
@@ -18,6 +25,7 @@ type SessionRow = {
|
||||
sessionID: SessionID;
|
||||
lastIdx: number;
|
||||
lastSignature: CojsonInternalTypes.Signature;
|
||||
bytesSinceLastSignature?: number;
|
||||
};
|
||||
|
||||
type StoredSessionRow = SessionRow & { rowID: number };
|
||||
@@ -28,6 +36,12 @@ type TransactionRow = {
|
||||
tx: CojsonInternalTypes.Transaction;
|
||||
};
|
||||
|
||||
type SignatureAfterRow = {
|
||||
ses: number;
|
||||
idx: number;
|
||||
signature: CojsonInternalTypes.Signature;
|
||||
};
|
||||
|
||||
export class IDBStorage {
|
||||
db: IDBDatabase;
|
||||
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
|
||||
@@ -49,7 +63,7 @@ export class IDBStorage {
|
||||
done = result.done;
|
||||
|
||||
if (result.value) {
|
||||
this.handleSyncMessage(result.value);
|
||||
await this.handleSyncMessage(result.value);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -82,42 +96,63 @@ export class IDBStorage {
|
||||
toLocalNode: WritableStream<SyncMessage>
|
||||
) {
|
||||
const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("jazz-storage", 1);
|
||||
const request = indexedDB.open("jazz-storage", 4);
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onupgradeneeded = () => {
|
||||
request.onupgradeneeded = async (ev) => {
|
||||
const db = request.result;
|
||||
if (ev.oldVersion === 0) {
|
||||
const coValues = db.createObjectStore("coValues", {
|
||||
autoIncrement: true,
|
||||
keyPath: "rowID",
|
||||
});
|
||||
|
||||
const coValues = db.createObjectStore("coValues", {
|
||||
autoIncrement: true,
|
||||
keyPath: "rowID",
|
||||
});
|
||||
|
||||
coValues.createIndex("coValuesById", "id", {
|
||||
unique: true,
|
||||
});
|
||||
|
||||
const sessions = db.createObjectStore("sessions", {
|
||||
autoIncrement: true,
|
||||
keyPath: "rowID",
|
||||
});
|
||||
|
||||
sessions.createIndex("sessionsByCoValue", "coValue");
|
||||
sessions.createIndex(
|
||||
"uniqueSessions",
|
||||
["coValue", "sessionID"],
|
||||
{
|
||||
coValues.createIndex("coValuesById", "id", {
|
||||
unique: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
db.createObjectStore("transactions", {
|
||||
keyPath: ["ses", "idx"],
|
||||
});
|
||||
const sessions = db.createObjectStore("sessions", {
|
||||
autoIncrement: true,
|
||||
keyPath: "rowID",
|
||||
});
|
||||
|
||||
sessions.createIndex("sessionsByCoValue", "coValue");
|
||||
sessions.createIndex(
|
||||
"uniqueSessions",
|
||||
["coValue", "sessionID"],
|
||||
{
|
||||
unique: true,
|
||||
}
|
||||
);
|
||||
|
||||
db.createObjectStore("transactions", {
|
||||
keyPath: ["ses", "idx"],
|
||||
});
|
||||
}
|
||||
if (ev.oldVersion <= 1) {
|
||||
db.createObjectStore("signatureAfter", {
|
||||
keyPath: ["ses", "idx"],
|
||||
});
|
||||
}
|
||||
if (ev.oldVersion !== 0 && ev.oldVersion <= 3) {
|
||||
// fix embarrassing off-by-one error for transaction indices
|
||||
console.log("Migration: fixing off-by-one error");
|
||||
const transaction = (ev.target as unknown as {transaction: IDBTransaction}).transaction;
|
||||
|
||||
const txsStore = transaction.objectStore("transactions");
|
||||
const txs = await promised(txsStore.getAll());
|
||||
|
||||
for (const tx of txs) {
|
||||
await promised(txsStore.delete([tx.ses, tx.idx]));
|
||||
tx.idx -= 1;
|
||||
await promised(txsStore.add(tx));
|
||||
}
|
||||
console.log("Migration: fixing off-by-one error - done");
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -147,10 +182,12 @@ export class IDBStorage {
|
||||
coValues,
|
||||
sessions,
|
||||
transactions,
|
||||
signatureAfter,
|
||||
}: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
},
|
||||
asDependencyOf?: CojsonInternalTypes.RawCoID
|
||||
) {
|
||||
@@ -170,12 +207,14 @@ export class IDBStorage {
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
const newContent: CojsonInternalTypes.NewContentMessage = {
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
header: theirKnown.header ? undefined : coValueRow?.header,
|
||||
new: {},
|
||||
};
|
||||
const newContentPieces: CojsonInternalTypes.NewContentMessage[] = [
|
||||
{
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
header: theirKnown.header ? undefined : coValueRow?.header,
|
||||
new: {},
|
||||
},
|
||||
];
|
||||
|
||||
for (const sessionRow of allOurSessions) {
|
||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||
@@ -187,6 +226,21 @@ export class IDBStorage {
|
||||
const firstNewTxIdx =
|
||||
theirKnown.sessions[sessionRow.sessionID] || 0;
|
||||
|
||||
const signaturesAndIdxs = await promised<SignatureAfterRow[]>(
|
||||
signatureAfter.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRow.rowID, firstNewTxIdx],
|
||||
[sessionRow.rowID, Infinity]
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// console.log(
|
||||
// theirKnown.id,
|
||||
// "signaturesAndIdxs",
|
||||
// JSON.stringify(signaturesAndIdxs)
|
||||
// );
|
||||
|
||||
const newTxInSession = await promised<TransactionRow[]>(
|
||||
transactions.getAll(
|
||||
IDBKeyRange.bound(
|
||||
@@ -196,36 +250,83 @@ export class IDBStorage {
|
||||
)
|
||||
);
|
||||
|
||||
newContent.new[sessionRow.sessionID] = {
|
||||
after: firstNewTxIdx,
|
||||
lastSignature: sessionRow.lastSignature,
|
||||
newTransactions: newTxInSession.map((row) => row.tx),
|
||||
};
|
||||
let idx = firstNewTxIdx;
|
||||
|
||||
// console.log(
|
||||
// theirKnown.id,
|
||||
// "newTxInSession",
|
||||
// newTxInSession.length
|
||||
// );
|
||||
|
||||
for (const tx of newTxInSession) {
|
||||
let sessionEntry =
|
||||
newContentPieces[newContentPieces.length - 1]!.new[
|
||||
sessionRow.sessionID
|
||||
];
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: idx,
|
||||
lastSignature: "WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
newTransactions: [],
|
||||
};
|
||||
newContentPieces[newContentPieces.length - 1]!.new[
|
||||
sessionRow.sessionID
|
||||
] = sessionEntry;
|
||||
}
|
||||
|
||||
sessionEntry.newTransactions.push(tx.tx);
|
||||
|
||||
if (
|
||||
signaturesAndIdxs[0] &&
|
||||
idx === signaturesAndIdxs[0].idx
|
||||
) {
|
||||
sessionEntry.lastSignature =
|
||||
signaturesAndIdxs[0].signature;
|
||||
signaturesAndIdxs.shift();
|
||||
newContentPieces.push({
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
new: {},
|
||||
});
|
||||
} else if (
|
||||
idx ===
|
||||
firstNewTxIdx + newTxInSession.length - 1
|
||||
) {
|
||||
sessionEntry.lastSignature = sessionRow.lastSignature;
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dependedOnCoValues =
|
||||
coValueRow?.header.ruleset.type === "group"
|
||||
? Object.values(newContent.new).flatMap((sessionEntry) =>
|
||||
sessionEntry.newTransactions.flatMap((tx) => {
|
||||
if (tx.privacy !== "trusting") return [];
|
||||
return tx.changes
|
||||
.map(
|
||||
(change) =>
|
||||
change &&
|
||||
typeof change === "object" &&
|
||||
"op" in change &&
|
||||
change.op === "set" &&
|
||||
"key" in change &&
|
||||
change.key
|
||||
)
|
||||
.filter(
|
||||
(key): key is CojsonInternalTypes.RawCoID =>
|
||||
typeof key === "string" &&
|
||||
key.startsWith("co_")
|
||||
);
|
||||
})
|
||||
)
|
||||
? newContentPieces
|
||||
.flatMap((piece) => Object.values(piece.new))
|
||||
.flatMap((sessionEntry) =>
|
||||
sessionEntry.newTransactions.flatMap((tx) => {
|
||||
if (tx.privacy !== "trusting") return [];
|
||||
// TODO: avoid parse here?
|
||||
return cojsonInternals
|
||||
.parseJSON(tx.changes)
|
||||
.map(
|
||||
(change) =>
|
||||
change &&
|
||||
typeof change === "object" &&
|
||||
"op" in change &&
|
||||
change.op === "set" &&
|
||||
"key" in change &&
|
||||
change.key
|
||||
)
|
||||
.filter(
|
||||
(
|
||||
key
|
||||
): key is CojsonInternalTypes.RawCoID =>
|
||||
typeof key === "string" &&
|
||||
key.startsWith("co_")
|
||||
);
|
||||
})
|
||||
)
|
||||
: coValueRow?.header.ruleset.type === "ownedByGroup"
|
||||
? [coValueRow?.header.ruleset.group]
|
||||
: [];
|
||||
@@ -233,7 +334,7 @@ export class IDBStorage {
|
||||
for (const dependedOnCoValue of dependedOnCoValues) {
|
||||
await this.sendNewContentAfter(
|
||||
{ id: dependedOnCoValue, header: false, sessions: {} },
|
||||
{ coValues, sessions, transactions },
|
||||
{ coValues, sessions, transactions, signatureAfter },
|
||||
asDependencyOf || theirKnown.id
|
||||
);
|
||||
}
|
||||
@@ -244,8 +345,15 @@ export class IDBStorage {
|
||||
asDependencyOf,
|
||||
});
|
||||
|
||||
if (newContent.header || Object.keys(newContent.new).length > 0) {
|
||||
await this.toLocalNode.write(newContent);
|
||||
const nonEmptyNewContentPieces = newContentPieces.filter(
|
||||
(piece) => piece.header || Object.keys(piece.new).length > 0
|
||||
);
|
||||
|
||||
// console.log(theirKnown.id, nonEmptyNewContentPieces);
|
||||
|
||||
for (const piece of nonEmptyNewContentPieces) {
|
||||
await this.toLocalNode.write(piece);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +362,7 @@ export class IDBStorage {
|
||||
}
|
||||
|
||||
async handleContent(msg: CojsonInternalTypes.NewContentMessage) {
|
||||
const { coValues, sessions, transactions } =
|
||||
const { coValues, sessions, transactions, signatureAfter } =
|
||||
this.inTransaction("readwrite");
|
||||
|
||||
let storedCoValueRowID = (
|
||||
@@ -325,18 +433,39 @@ export class IDBStorage {
|
||||
const actuallyNewOffset =
|
||||
(sessionRow?.lastIdx || 0) -
|
||||
(msg.new[sessionID]?.after || 0);
|
||||
|
||||
const actuallyNewTransactions =
|
||||
newTransactions.slice(actuallyNewOffset);
|
||||
|
||||
let newBytesSinceLastSignature =
|
||||
(sessionRow?.bytesSinceLastSignature || 0) +
|
||||
actuallyNewTransactions.reduce(
|
||||
(sum, tx) =>
|
||||
sum +
|
||||
(tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length),
|
||||
0
|
||||
);
|
||||
|
||||
const newLastIdx =
|
||||
(sessionRow?.lastIdx || 0) + actuallyNewTransactions.length;
|
||||
|
||||
let shouldWriteSignature = false;
|
||||
|
||||
if (newBytesSinceLastSignature > MAX_RECOMMENDED_TX_SIZE) {
|
||||
shouldWriteSignature = true;
|
||||
newBytesSinceLastSignature = 0;
|
||||
}
|
||||
|
||||
let nextIdx = sessionRow?.lastIdx || 0;
|
||||
|
||||
const sessionUpdate = {
|
||||
coValue: storedCoValueRowID,
|
||||
sessionID: sessionID,
|
||||
lastIdx:
|
||||
(sessionRow?.lastIdx || 0) +
|
||||
actuallyNewTransactions.length,
|
||||
lastIdx: newLastIdx,
|
||||
lastSignature: msg.new[sessionID]!.lastSignature,
|
||||
bytesSinceLastSignature: newBytesSinceLastSignature,
|
||||
};
|
||||
|
||||
const sessionRowID = (await promised(
|
||||
@@ -350,8 +479,18 @@ export class IDBStorage {
|
||||
)
|
||||
)) as number;
|
||||
|
||||
if (shouldWriteSignature) {
|
||||
await promised(
|
||||
signatureAfter.put({
|
||||
ses: sessionRowID,
|
||||
// TODO: newLastIdx is a misnomer, it's actually more like nextIdx or length
|
||||
idx: newLastIdx - 1,
|
||||
signature: msg.new[sessionID]!.lastSignature,
|
||||
} satisfies SignatureAfterRow)
|
||||
);
|
||||
}
|
||||
|
||||
for (const newTransaction of actuallyNewTransactions) {
|
||||
nextIdx++;
|
||||
await promised(
|
||||
transactions.add({
|
||||
ses: sessionRowID,
|
||||
@@ -359,6 +498,7 @@ export class IDBStorage {
|
||||
tx: newTransaction,
|
||||
} satisfies TransactionRow)
|
||||
);
|
||||
nextIdx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,9 +522,10 @@ export class IDBStorage {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
signatureAfter: IDBObjectStore;
|
||||
} {
|
||||
const tx = this.db.transaction(
|
||||
["coValues", "sessions", "transactions"],
|
||||
["coValues", "sessions", "transactions", "signatureAfter"],
|
||||
mode
|
||||
);
|
||||
|
||||
@@ -401,8 +542,9 @@ export class IDBStorage {
|
||||
const coValues = tx.objectStore("coValues");
|
||||
const sessions = tx.objectStore("sessions");
|
||||
const transactions = tx.objectStore("transactions");
|
||||
const signatureAfter = tx.objectStore("signatureAfter");
|
||||
|
||||
return { coValues, sessions, transactions };
|
||||
return { coValues, sessions, transactions, signatureAfter };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.1.10",
|
||||
"version": "0.3.6",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.5.2",
|
||||
"cojson": "^0.1.12",
|
||||
"cojson": "^0.3.4",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -4,8 +4,7 @@ import {
|
||||
Peer,
|
||||
CojsonInternalTypes,
|
||||
SessionID,
|
||||
// CojsonInternalTypes,
|
||||
// SessionID,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
} from "cojson";
|
||||
import {
|
||||
ReadableStream,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
} from "isomorphic-streams";
|
||||
|
||||
import Database, { Database as DatabaseT } from "better-sqlite3";
|
||||
import { RawCoID } from "cojson/dist/ids";
|
||||
|
||||
type CoValueRow = {
|
||||
id: CojsonInternalTypes.RawCoID;
|
||||
@@ -29,6 +27,7 @@ type SessionRow = {
|
||||
sessionID: SessionID;
|
||||
lastIdx: number;
|
||||
lastSignature: CojsonInternalTypes.Signature;
|
||||
bytesSinceLastSignature?: number;
|
||||
};
|
||||
|
||||
type StoredSessionRow = SessionRow & { rowID: number };
|
||||
@@ -39,6 +38,12 @@ type TransactionRow = {
|
||||
tx: string;
|
||||
};
|
||||
|
||||
type SignatureAfterRow = {
|
||||
ses: number;
|
||||
idx: number;
|
||||
signature: CojsonInternalTypes.Signature;
|
||||
};
|
||||
|
||||
export class SQLiteStorage {
|
||||
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
|
||||
toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
|
||||
@@ -60,7 +65,7 @@ export class SQLiteStorage {
|
||||
done = result.done;
|
||||
|
||||
if (result.value) {
|
||||
this.handleSyncMessage(result.value);
|
||||
await this.handleSyncMessage(result.value);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -98,41 +103,99 @@ export class SQLiteStorage {
|
||||
const db = Database(filename);
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS transactions (
|
||||
ses INTEGER,
|
||||
idx INTEGER,
|
||||
tx TEXT NOT NULL ,
|
||||
PRIMARY KEY (ses, idx)
|
||||
) WITHOUT ROWID;`
|
||||
).run();
|
||||
const oldVersion = (
|
||||
db.pragma("user_version") as [{ user_version: number }]
|
||||
)[0].user_version as number;
|
||||
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
rowID INTEGER PRIMARY KEY,
|
||||
coValue INTEGER NOT NULL,
|
||||
sessionID TEXT NOT NULL,
|
||||
lastIdx INTEGER,
|
||||
lastSignature TEXT,
|
||||
UNIQUE (sessionID, coValue)
|
||||
);`
|
||||
).run();
|
||||
console.log("DB version", oldVersion);
|
||||
|
||||
db.prepare(
|
||||
`CREATE INDEX IF NOT EXISTS sessionsByCoValue ON sessions (coValue);`
|
||||
).run();
|
||||
if (oldVersion === 0) {
|
||||
console.log("Migration 0 -> 1: Basic schema");
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS transactions (
|
||||
ses INTEGER,
|
||||
idx INTEGER,
|
||||
tx TEXT NOT NULL,
|
||||
PRIMARY KEY (ses, idx)
|
||||
) WITHOUT ROWID;`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS coValues (
|
||||
rowID INTEGER PRIMARY KEY,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
header TEXT NOT NULL UNIQUE
|
||||
);`
|
||||
).run();
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
rowID INTEGER PRIMARY KEY,
|
||||
coValue INTEGER NOT NULL,
|
||||
sessionID TEXT NOT NULL,
|
||||
lastIdx INTEGER,
|
||||
lastSignature TEXT,
|
||||
UNIQUE (sessionID, coValue)
|
||||
);`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`CREATE INDEX IF NOT EXISTS coValuesByID ON coValues (id);`
|
||||
).run();
|
||||
db.prepare(
|
||||
`CREATE INDEX IF NOT EXISTS sessionsByCoValue ON sessions (coValue);`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS coValues (
|
||||
rowID INTEGER PRIMARY KEY,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
header TEXT NOT NULL UNIQUE
|
||||
);`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`CREATE INDEX IF NOT EXISTS coValuesByID ON coValues (id);`
|
||||
).run();
|
||||
|
||||
db.pragma("user_version = 1");
|
||||
console.log("Migration 0 -> 1: Basic schema - done");
|
||||
}
|
||||
|
||||
if (oldVersion <= 1) {
|
||||
// fix embarrassing off-by-one error for transaction indices
|
||||
console.log(
|
||||
"Migration 1 -> 2: Fix off-by-one error for transaction indices"
|
||||
);
|
||||
|
||||
const txs = db
|
||||
.prepare(`SELECT * FROM transactions`)
|
||||
.all() as TransactionRow[];
|
||||
|
||||
for (const tx of txs) {
|
||||
db.prepare(
|
||||
`DELETE FROM transactions WHERE ses = ? AND idx = ?`
|
||||
).run(tx.ses, tx.idx);
|
||||
tx.idx -= 1;
|
||||
db.prepare(
|
||||
`INSERT INTO transactions (ses, idx, tx) VALUES (?, ?, ?)`
|
||||
).run(tx.ses, tx.idx, tx.tx);
|
||||
}
|
||||
|
||||
db.pragma("user_version = 2");
|
||||
console.log(
|
||||
"Migration 1 -> 2: Fix off-by-one error for transaction indices - done"
|
||||
);
|
||||
}
|
||||
|
||||
if (oldVersion <= 2) {
|
||||
console.log("Migration 2 -> 3: Add signatureAfter");
|
||||
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS signatureAfter (
|
||||
ses INTEGER,
|
||||
idx INTEGER,
|
||||
signature TEXT NOT NULL,
|
||||
PRIMARY KEY (ses, idx)
|
||||
) WITHOUT ROWID;`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`ALTER TABLE sessions ADD COLUMN bytesSinceLastSignature INTEGER;`
|
||||
).run();
|
||||
|
||||
db.pragma("user_version = 3");
|
||||
console.log("Migration 2 -> 3: Add signatureAfter - done");
|
||||
}
|
||||
|
||||
return new SQLiteStorage(db, fromLocalNode, toLocalNode);
|
||||
}
|
||||
@@ -174,17 +237,31 @@ export class SQLiteStorage {
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
const parsedHeader = (coValueRow?.header &&
|
||||
JSON.parse(coValueRow.header)) as
|
||||
| CojsonInternalTypes.CoValueHeader
|
||||
| undefined;
|
||||
let parsedHeader;
|
||||
|
||||
const newContent: CojsonInternalTypes.NewContentMessage = {
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
header: theirKnown.header ? undefined : parsedHeader,
|
||||
new: {},
|
||||
};
|
||||
try {
|
||||
parsedHeader = (coValueRow?.header &&
|
||||
JSON.parse(coValueRow.header)) as
|
||||
| CojsonInternalTypes.CoValueHeader
|
||||
| undefined;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
theirKnown.id,
|
||||
"Invalid JSON in header",
|
||||
e,
|
||||
coValueRow?.header
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newContentPieces: CojsonInternalTypes.NewContentMessage[] = [
|
||||
{
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
header: theirKnown.header ? undefined : parsedHeader,
|
||||
new: {},
|
||||
},
|
||||
];
|
||||
|
||||
for (const sessionRow of allOurSessions) {
|
||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||
@@ -196,44 +273,134 @@ export class SQLiteStorage {
|
||||
const firstNewTxIdx =
|
||||
theirKnown.sessions[sessionRow.sessionID] || 0;
|
||||
|
||||
const signaturesAndIdxs = this.db
|
||||
.prepare<[number, number]>(
|
||||
`SELECT * FROM signatureAfter WHERE ses = ? AND idx >= ?`
|
||||
)
|
||||
.all(
|
||||
sessionRow.rowID,
|
||||
firstNewTxIdx
|
||||
) as SignatureAfterRow[];
|
||||
|
||||
// console.log(
|
||||
// theirKnown.id,
|
||||
// "signaturesAndIdxs",
|
||||
// JSON.stringify(signaturesAndIdxs)
|
||||
// );
|
||||
|
||||
const newTxInSession = this.db
|
||||
.prepare<[number, number]>(
|
||||
`SELECT * FROM transactions WHERE ses = ? AND idx > ?`
|
||||
`SELECT * FROM transactions WHERE ses = ? AND idx >= ?`
|
||||
)
|
||||
.all(sessionRow.rowID, firstNewTxIdx) as TransactionRow[];
|
||||
|
||||
newContent.new[sessionRow.sessionID] = {
|
||||
after: firstNewTxIdx,
|
||||
lastSignature: sessionRow.lastSignature,
|
||||
newTransactions: newTxInSession.map((row) =>
|
||||
JSON.parse(row.tx)
|
||||
),
|
||||
};
|
||||
let idx = firstNewTxIdx;
|
||||
|
||||
// console.log(
|
||||
// theirKnown.id,
|
||||
// "newTxInSession",
|
||||
// newTxInSession.length
|
||||
// );
|
||||
|
||||
for (const tx of newTxInSession) {
|
||||
let sessionEntry =
|
||||
newContentPieces[newContentPieces.length - 1]!.new[
|
||||
sessionRow.sessionID
|
||||
];
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: idx,
|
||||
lastSignature:
|
||||
"WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
newTransactions: [],
|
||||
};
|
||||
newContentPieces[newContentPieces.length - 1]!.new[
|
||||
sessionRow.sessionID
|
||||
] = sessionEntry;
|
||||
}
|
||||
|
||||
let parsedTx;
|
||||
|
||||
try {
|
||||
parsedTx = JSON.parse(tx.tx);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
theirKnown.id,
|
||||
"Invalid JSON in transaction",
|
||||
e,
|
||||
tx.tx
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
sessionEntry.newTransactions.push(parsedTx);
|
||||
|
||||
if (
|
||||
signaturesAndIdxs[0] &&
|
||||
idx === signaturesAndIdxs[0].idx
|
||||
) {
|
||||
sessionEntry.lastSignature =
|
||||
signaturesAndIdxs[0].signature;
|
||||
signaturesAndIdxs.shift();
|
||||
newContentPieces.push({
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
new: {},
|
||||
});
|
||||
} else if (
|
||||
idx ===
|
||||
firstNewTxIdx + newTxInSession.length - 1
|
||||
) {
|
||||
sessionEntry.lastSignature = sessionRow.lastSignature;
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dependedOnCoValues =
|
||||
parsedHeader?.ruleset.type === "group"
|
||||
? Object.values(newContent.new).flatMap((sessionEntry) =>
|
||||
sessionEntry.newTransactions.flatMap((tx) => {
|
||||
if (tx.privacy !== "trusting") return [];
|
||||
return tx.changes
|
||||
.map(
|
||||
(change) =>
|
||||
change &&
|
||||
typeof change === "object" &&
|
||||
"op" in change &&
|
||||
change.op === "set" &&
|
||||
"key" in change &&
|
||||
change.key
|
||||
)
|
||||
.filter(
|
||||
(key): key is CojsonInternalTypes.RawCoID =>
|
||||
typeof key === "string" &&
|
||||
key.startsWith("co_")
|
||||
);
|
||||
})
|
||||
)
|
||||
? newContentPieces
|
||||
.flatMap((piece) => Object.values(piece.new))
|
||||
.flatMap((sessionEntry) =>
|
||||
sessionEntry.newTransactions.flatMap((tx) => {
|
||||
if (tx.privacy !== "trusting") return [];
|
||||
// TODO: avoid parsing here?
|
||||
let parsedChanges;
|
||||
|
||||
try {
|
||||
parsedChanges = cojsonInternals.parseJSON(
|
||||
tx.changes
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
theirKnown.id,
|
||||
"Invalid JSON in transaction",
|
||||
e,
|
||||
tx.changes
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsedChanges
|
||||
.map(
|
||||
(change) =>
|
||||
change &&
|
||||
typeof change === "object" &&
|
||||
"op" in change &&
|
||||
change.op === "set" &&
|
||||
"key" in change &&
|
||||
change.key
|
||||
)
|
||||
.filter(
|
||||
(
|
||||
key
|
||||
): key is CojsonInternalTypes.RawCoID =>
|
||||
typeof key === "string" &&
|
||||
key.startsWith("co_")
|
||||
);
|
||||
})
|
||||
)
|
||||
: parsedHeader?.ruleset.type === "ownedByGroup"
|
||||
? [parsedHeader?.ruleset.group]
|
||||
: [];
|
||||
@@ -251,8 +418,15 @@ export class SQLiteStorage {
|
||||
asDependencyOf,
|
||||
});
|
||||
|
||||
if (newContent.header || Object.keys(newContent.new).length > 0) {
|
||||
await this.toLocalNode.write(newContent);
|
||||
const nonEmptyNewContentPieces = newContentPieces.filter(
|
||||
(piece) => piece.header || Object.keys(piece.new).length > 0
|
||||
);
|
||||
|
||||
// console.log(theirKnown.id, nonEmptyNewContentPieces);
|
||||
|
||||
for (const piece of nonEmptyNewContentPieces) {
|
||||
await this.toLocalNode.write(piece);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +437,9 @@ export class SQLiteStorage {
|
||||
async handleContent(msg: CojsonInternalTypes.NewContentMessage) {
|
||||
let storedCoValueRowID = (
|
||||
this.db
|
||||
.prepare<RawCoID>(`SELECT rowID FROM coValues WHERE id = ?`)
|
||||
.prepare<CojsonInternalTypes.RawCoID>(
|
||||
`SELECT rowID FROM coValues WHERE id = ?`
|
||||
)
|
||||
.get(msg.id) as StoredCoValueRow | undefined
|
||||
)?.rowID;
|
||||
|
||||
@@ -282,7 +458,7 @@ export class SQLiteStorage {
|
||||
}
|
||||
|
||||
storedCoValueRowID = this.db
|
||||
.prepare<[RawCoID, string]>(
|
||||
.prepare<[CojsonInternalTypes.RawCoID, string]>(
|
||||
`INSERT INTO coValues (id, header) VALUES (?, ?)`
|
||||
)
|
||||
.run(msg.id, JSON.stringify(header)).lastInsertRowid as number;
|
||||
@@ -324,37 +500,72 @@ export class SQLiteStorage {
|
||||
const actuallyNewOffset =
|
||||
(sessionRow?.lastIdx || 0) -
|
||||
(msg.new[sessionID]?.after || 0);
|
||||
|
||||
const actuallyNewTransactions =
|
||||
newTransactions.slice(actuallyNewOffset);
|
||||
|
||||
let newBytesSinceLastSignature =
|
||||
(sessionRow?.bytesSinceLastSignature || 0) +
|
||||
actuallyNewTransactions.reduce(
|
||||
(sum, tx) =>
|
||||
sum +
|
||||
(tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length),
|
||||
0
|
||||
);
|
||||
|
||||
const newLastIdx =
|
||||
(sessionRow?.lastIdx || 0) +
|
||||
actuallyNewTransactions.length;
|
||||
|
||||
let shouldWriteSignature = false;
|
||||
|
||||
if (newBytesSinceLastSignature > MAX_RECOMMENDED_TX_SIZE) {
|
||||
shouldWriteSignature = true;
|
||||
newBytesSinceLastSignature = 0;
|
||||
}
|
||||
|
||||
let nextIdx = sessionRow?.lastIdx || 0;
|
||||
|
||||
const sessionUpdate = {
|
||||
coValue: storedCoValueRowID!,
|
||||
sessionID: sessionID,
|
||||
lastIdx:
|
||||
(sessionRow?.lastIdx || 0) +
|
||||
actuallyNewTransactions.length,
|
||||
lastIdx: newLastIdx,
|
||||
lastSignature: msg.new[sessionID]!.lastSignature,
|
||||
bytesSinceLastSignature: newBytesSinceLastSignature,
|
||||
};
|
||||
|
||||
const upsertedSession = (this.db
|
||||
.prepare<[number, string, number, string]>(
|
||||
`INSERT INTO sessions (coValue, sessionID, lastIdx, lastSignature) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(coValue, sessionID) DO UPDATE SET lastIdx=excluded.lastIdx, lastSignature=excluded.lastSignature
|
||||
const upsertedSession = this.db
|
||||
.prepare<[number, string, number, string, number]>(
|
||||
`INSERT INTO sessions (coValue, sessionID, lastIdx, lastSignature, bytesSinceLastSignature) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(coValue, sessionID) DO UPDATE SET lastIdx=excluded.lastIdx, lastSignature=excluded.lastSignature, bytesSinceLastSignature=excluded.bytesSinceLastSignature
|
||||
RETURNING rowID`
|
||||
)
|
||||
.get(
|
||||
sessionUpdate.coValue,
|
||||
sessionUpdate.sessionID,
|
||||
sessionUpdate.lastIdx,
|
||||
sessionUpdate.lastSignature
|
||||
) as {rowID: number});
|
||||
sessionUpdate.lastSignature,
|
||||
sessionUpdate.bytesSinceLastSignature
|
||||
) as { rowID: number };
|
||||
|
||||
const sessionRowID = upsertedSession.rowID;
|
||||
|
||||
if (shouldWriteSignature) {
|
||||
this.db
|
||||
.prepare<[number, number, string]>(
|
||||
`INSERT INTO signatureAfter (ses, idx, signature) VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
sessionRowID,
|
||||
// TODO: newLastIdx is a misnomer, it's actually more like nextIdx or length
|
||||
newLastIdx - 1,
|
||||
msg.new[sessionID]!.lastSignature
|
||||
);
|
||||
}
|
||||
|
||||
for (const newTransaction of actuallyNewTransactions) {
|
||||
nextIdx++;
|
||||
this.db
|
||||
.prepare<[number, number, string]>(
|
||||
`INSERT INTO transactions (ses, idx, tx) VALUES (?, ?, ?)`
|
||||
@@ -364,6 +575,7 @@ export class SQLiteStorage {
|
||||
nextIdx,
|
||||
JSON.stringify(newTransaction)
|
||||
);
|
||||
nextIdx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ module.exports = {
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
ignorePatterns: [".eslint.cjs", "**/tests/*"],
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.1.12",
|
||||
"version": "0.3.4",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "./crypto.js";
|
||||
import { AgentID } from "./ids.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import { Group, GroupContent } from "./group.js";
|
||||
|
||||
export function accountHeaderForInitialAgentSecret(
|
||||
@@ -32,7 +32,7 @@ export function accountHeaderForInitialAgentSecret(
|
||||
};
|
||||
}
|
||||
|
||||
export class Account extends Group {
|
||||
export class AccountGroup extends Group {
|
||||
get id(): AccountID {
|
||||
return this.underlyingMap.id as AccountID;
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export interface GeneralizedControlledAccount {
|
||||
|
||||
/** @hidden */
|
||||
export class ControlledAccount
|
||||
extends Account
|
||||
extends AccountGroup
|
||||
implements GeneralizedControlledAccount
|
||||
{
|
||||
agentSecret: AgentSecret;
|
||||
@@ -136,10 +136,10 @@ export class AnonymousControlledAccount
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountContent = GroupContent & { profile: CoID<Profile> };
|
||||
export type AccountContent = { profile: Profile } & GroupContent;
|
||||
export type AccountMeta = { type: "account" };
|
||||
export type AccountMap = CoMap<AccountContent, AccountMeta>;
|
||||
export type AccountID = CoID<AccountMap>;
|
||||
export type Account = CoMap<AccountContent, AccountMeta>;
|
||||
export type AccountID = CoID<Account>;
|
||||
|
||||
export function isAccountID(id: AccountID | AgentID): id is AccountID {
|
||||
return id.startsWith("co_");
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { RawCoID } from "./ids.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { BinaryCoStream, BinaryCoStreamMeta, CoStream } from "./coValues/coStream.js";
|
||||
import { Static } from "./coValues/static.js";
|
||||
import {
|
||||
BinaryCoStream,
|
||||
BinaryCoStreamMeta,
|
||||
CoStream,
|
||||
} from "./coValues/coStream.js";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import { CoValueCore } from "./coValueCore.js";
|
||||
import { Group } from "./group.js";
|
||||
|
||||
export type CoID<T extends CoValueImpl> = RawCoID & {
|
||||
export type CoID<T extends CoValue> = RawCoID & {
|
||||
readonly __type: T;
|
||||
};
|
||||
|
||||
export interface ReadableCoValue extends CoValue {
|
||||
export interface CoValue {
|
||||
/** The `CoValue`'s (precisely typed) `CoID` */
|
||||
id: CoID<this>;
|
||||
core: CoValueCore;
|
||||
/** Specifies which kind of `CoValue` this is */
|
||||
type: string;
|
||||
/** The `CoValue`'s (precisely typed) static metadata */
|
||||
meta: JsonObject | null;
|
||||
/** The `Group` this `CoValue` belongs to (determining permissions) */
|
||||
group: Group;
|
||||
/** Returns an immutable JSON presentation of this `CoValue` */
|
||||
toJSON(): JsonValue;
|
||||
atTime(time: number): this;
|
||||
/** Lets you subscribe to future updates to this CoValue (whether made locally or by other users).
|
||||
*
|
||||
* Takes a listener function that will be called with the current state for each update.
|
||||
@@ -19,46 +34,62 @@ export interface ReadableCoValue extends CoValue {
|
||||
* Returns an unsubscribe function.
|
||||
*
|
||||
* Used internally by `useTelepathicData()` for reactive updates on changes to a `CoValue`. */
|
||||
subscribe(listener: (coValue: CoValueImpl) => void): () => void;
|
||||
/** Lets you apply edits to a `CoValue`, inside the changer callback, which receives a `WriteableCoValue`.
|
||||
*
|
||||
* A `WritableCoValue` has all the same methods as a `CoValue`, but all edits made to it (with its additional mutator methods)
|
||||
* are reflected in it immediately - so it behaves mutably, whereas a `CoValue` is always immutable
|
||||
* (you need to use `subscribe` to receive new versions of it). */
|
||||
edit?:
|
||||
| ((changer: (editable: WriteableCoValue) => void) => CoValueImpl)
|
||||
| undefined;
|
||||
subscribe(listener: (coValue: this) => void): () => void;
|
||||
}
|
||||
|
||||
export interface CoValue {
|
||||
/** The `CoValue`'s (precisely typed) `CoID` */
|
||||
id: CoID<CoValueImpl>;
|
||||
core: CoValueCore;
|
||||
/** Specifies which kind of `CoValue` this is */
|
||||
type: CoValueImpl["type"];
|
||||
/** The `CoValue`'s (precisely typed) static metadata */
|
||||
meta: JsonObject | null;
|
||||
/** The `Group` this `CoValue` belongs to (determining permissions) */
|
||||
group: Group;
|
||||
/** Returns an immutable JSON presentation of this `CoValue` */
|
||||
toJSON(): JsonValue;
|
||||
}
|
||||
export type AnyCoMap = CoMap<
|
||||
{ [key: string]: JsonValue | CoValue | undefined },
|
||||
JsonObject | null
|
||||
>;
|
||||
|
||||
export interface WriteableCoValue extends CoValue {}
|
||||
export type AnyCoList = CoList<JsonValue | CoValue, JsonObject | null>;
|
||||
|
||||
export type CoValueImpl =
|
||||
| CoMap<{ [key: string]: JsonValue }, JsonObject | null>
|
||||
| CoList<JsonValue, JsonObject | null>
|
||||
| CoStream<JsonValue, JsonObject | null>
|
||||
| BinaryCoStream<BinaryCoStreamMeta>
|
||||
| Static<JsonObject>;
|
||||
export type AnyCoStream = CoStream<JsonValue | CoValue, JsonObject | null>;
|
||||
|
||||
export type AnyBinaryCoStream = BinaryCoStream<BinaryCoStreamMeta>;
|
||||
|
||||
|
||||
export type AnyCoValue =
|
||||
| AnyCoMap
|
||||
| AnyCoList
|
||||
| AnyCoStream
|
||||
| AnyBinaryCoStream
|
||||
|
||||
export function expectMap(
|
||||
content: CoValueImpl
|
||||
): CoMap<{ [key: string]: string }, JsonObject | null> {
|
||||
content: CoValue
|
||||
): AnyCoMap {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<{ [key: string]: string }, JsonObject | null>;
|
||||
return content as AnyCoMap;
|
||||
}
|
||||
|
||||
export function expectList(
|
||||
content: CoValue
|
||||
): AnyCoList {
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
return content as AnyCoList;
|
||||
}
|
||||
|
||||
export function expectStream(
|
||||
content: CoValue
|
||||
): AnyCoStream {
|
||||
if (content.type !== "costream") {
|
||||
throw new Error("Expected stream");
|
||||
}
|
||||
|
||||
return content as AnyCoStream;
|
||||
}
|
||||
|
||||
export function isCoValue(value: JsonValue | CoValue | undefined) : value is CoValue {
|
||||
return (
|
||||
value instanceof CoMap ||
|
||||
value instanceof CoList ||
|
||||
value instanceof CoStream ||
|
||||
value instanceof BinaryCoStream
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
import { CoValueImpl } from "./coValue.js";
|
||||
import { Static } from "./coValues/static.js";
|
||||
import { AnyCoValue, CoValue } from "./coValue.js";
|
||||
import { BinaryCoStream, CoStream } from "./coValues/coStream.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import {
|
||||
@@ -14,11 +13,11 @@ import {
|
||||
sign,
|
||||
verify,
|
||||
encryptForTransaction,
|
||||
decryptForTransaction,
|
||||
KeyID,
|
||||
decryptKeySecret,
|
||||
getAgentSignerID,
|
||||
getAgentSealerID,
|
||||
decryptRawForTransaction,
|
||||
} from "./crypto.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { base58 } from "@scure/base";
|
||||
@@ -28,17 +27,17 @@ import {
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
||||
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import {
|
||||
AccountID,
|
||||
GeneralizedControlledAccount,
|
||||
} from "./account.js";
|
||||
import { AccountID, GeneralizedControlledAccount } from "./account.js";
|
||||
import { Stringified, stableStringify } from "./jsonStringify.js";
|
||||
|
||||
export const MAX_RECOMMENDED_TX_SIZE = 100 * 1024;
|
||||
|
||||
export type CoValueHeader = {
|
||||
type: CoValueImpl["type"];
|
||||
type: AnyCoValue["type"];
|
||||
ruleset: RulesetDef;
|
||||
meta: JsonObject | null;
|
||||
createdAt: `2${string}` | null;
|
||||
@@ -64,6 +63,7 @@ type SessionLog = {
|
||||
transactions: Transaction[];
|
||||
lastHash?: Hash;
|
||||
streamingHash: StreamingHash;
|
||||
signatureAfter: { [txIdx: number]: Signature | undefined };
|
||||
lastSignature: Signature;
|
||||
};
|
||||
|
||||
@@ -80,14 +80,14 @@ export type PrivateTransaction = {
|
||||
export type TrustingTransaction = {
|
||||
privacy: "trusting";
|
||||
madeAt: number;
|
||||
changes: JsonValue[];
|
||||
changes: Stringified<JsonValue[]>;
|
||||
};
|
||||
|
||||
export type Transaction = PrivateTransaction | TrustingTransaction;
|
||||
|
||||
export type DecryptedTransaction = {
|
||||
txID: TransactionID;
|
||||
changes: JsonValue[];
|
||||
changes: Stringified<JsonValue[]>;
|
||||
madeAt: number;
|
||||
};
|
||||
|
||||
@@ -98,9 +98,13 @@ export class CoValueCore {
|
||||
node: LocalNode;
|
||||
header: CoValueHeader;
|
||||
_sessions: { [key: SessionID]: SessionLog };
|
||||
_cachedContent?: CoValueImpl;
|
||||
listeners: Set<(content?: CoValueImpl) => void> = new Set();
|
||||
_decryptionCache: {[key: Encrypted<JsonValue[], JsonValue>]: JsonValue[] | undefined} = {}
|
||||
_cachedContent?: CoValue;
|
||||
listeners: Set<(content?: CoValue) => void> = new Set();
|
||||
_decryptionCache: {
|
||||
[key: Encrypted<JsonValue[], JsonValue>]:
|
||||
| Stringified<JsonValue[]>
|
||||
| undefined;
|
||||
} = {};
|
||||
|
||||
constructor(
|
||||
header: CoValueHeader,
|
||||
@@ -209,7 +213,8 @@ export class CoValueCore {
|
||||
// const beforeVerify = performance.now();
|
||||
if (!verify(newSignature, expectedNewHash, signerID)) {
|
||||
console.warn(
|
||||
"Invalid signature",
|
||||
"Invalid signature in",
|
||||
this.id,
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
signerID
|
||||
@@ -222,25 +227,13 @@ export class CoValueCore {
|
||||
// afterVerify - beforeVerify
|
||||
// );
|
||||
|
||||
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
||||
|
||||
transactions.push(...newTransactions);
|
||||
|
||||
this._sessions[sessionID] = {
|
||||
transactions,
|
||||
lastHash: expectedNewHash,
|
||||
streamingHash: newStreamingHash,
|
||||
lastSignature: newSignature,
|
||||
};
|
||||
|
||||
this._cachedContent = undefined;
|
||||
|
||||
if (this.listeners.size > 0) {
|
||||
const content = this.getCurrentContent();
|
||||
for (const listener of this.listeners) {
|
||||
listener(content);
|
||||
}
|
||||
}
|
||||
this.doAddTransactions(
|
||||
sessionID,
|
||||
newTransactions,
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
newStreamingHash
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -269,10 +262,8 @@ export class CoValueCore {
|
||||
const nTxBefore = this.sessions[sessionID]?.transactions.length ?? 0;
|
||||
|
||||
// const beforeHash = performance.now();
|
||||
const { expectedNewHash, newStreamingHash } = await this.expectedNewHashAfterAsync(
|
||||
sessionID,
|
||||
newTransactions
|
||||
);
|
||||
const { expectedNewHash, newStreamingHash } =
|
||||
await this.expectedNewHashAfterAsync(sessionID, newTransactions);
|
||||
// const afterHash = performance.now();
|
||||
// console.log(
|
||||
// "Hashing took",
|
||||
@@ -283,7 +274,7 @@ export class CoValueCore {
|
||||
|
||||
if (nTxAfter !== nTxBefore) {
|
||||
const newTransactionLengthBefore = newTransactions.length;
|
||||
newTransactions = newTransactions.slice((nTxAfter - nTxBefore));
|
||||
newTransactions = newTransactions.slice(nTxAfter - nTxBefore);
|
||||
console.warn("Transactions changed while async hashing", {
|
||||
nTxBefore,
|
||||
nTxAfter,
|
||||
@@ -303,7 +294,8 @@ export class CoValueCore {
|
||||
// const beforeVerify = performance.now();
|
||||
if (!verify(newSignature, expectedNewHash, signerID)) {
|
||||
console.warn(
|
||||
"Invalid signature",
|
||||
"Invalid signature in",
|
||||
this.id,
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
signerID
|
||||
@@ -316,15 +308,61 @@ export class CoValueCore {
|
||||
// afterVerify - beforeVerify
|
||||
// );
|
||||
|
||||
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
||||
this.doAddTransactions(
|
||||
sessionID,
|
||||
newTransactions,
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
newStreamingHash
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private doAddTransactions(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
newSignature: Signature,
|
||||
expectedNewHash: Hash,
|
||||
newStreamingHash: StreamingHash
|
||||
) {
|
||||
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
||||
transactions.push(...newTransactions);
|
||||
|
||||
const signatureAfter = this.sessions[sessionID]?.signatureAfter ?? {};
|
||||
|
||||
const lastInbetweenSignatureIdx = Object.keys(signatureAfter).reduce(
|
||||
(max, idx) => (parseInt(idx) > max ? parseInt(idx) : max),
|
||||
-1
|
||||
);
|
||||
|
||||
const sizeOfTxsSinceLastInbetweenSignature = transactions
|
||||
.slice(lastInbetweenSignatureIdx + 1)
|
||||
.reduce(
|
||||
(sum, tx) =>
|
||||
sum +
|
||||
(tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length),
|
||||
0
|
||||
);
|
||||
|
||||
if (sizeOfTxsSinceLastInbetweenSignature > 100 * 1024) {
|
||||
// console.log(
|
||||
// "Saving inbetween signature for tx ",
|
||||
// sessionID,
|
||||
// transactions.length - 1,
|
||||
// sizeOfTxsSinceLastInbetweenSignature
|
||||
// );
|
||||
signatureAfter[transactions.length - 1] = newSignature;
|
||||
}
|
||||
|
||||
this._sessions[sessionID] = {
|
||||
transactions,
|
||||
lastHash: expectedNewHash,
|
||||
streamingHash: newStreamingHash,
|
||||
lastSignature: newSignature,
|
||||
signatureAfter: signatureAfter,
|
||||
};
|
||||
|
||||
this._cachedContent = undefined;
|
||||
@@ -335,11 +373,9 @@ export class CoValueCore {
|
||||
listener(content);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
subscribe(listener: (content?: CoValueImpl) => void): () => void {
|
||||
subscribe(listener: (content?: CoValue) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getCurrentContent());
|
||||
|
||||
@@ -376,10 +412,10 @@ export class CoValueCore {
|
||||
new StreamingHash();
|
||||
let before = performance.now();
|
||||
for (const transaction of newTransactions) {
|
||||
streamingHash.update(transaction)
|
||||
streamingHash.update(transaction);
|
||||
const after = performance.now();
|
||||
if (after - before > 1) {
|
||||
console.log("Hashing blocked for", after - before);
|
||||
// console.log("Hashing blocked for", after - before);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
before = performance.now();
|
||||
}
|
||||
@@ -415,7 +451,7 @@ export class CoValueCore {
|
||||
tx: this.nextTransactionID(),
|
||||
});
|
||||
|
||||
this._decryptionCache[encrypted] = changes;
|
||||
this._decryptionCache[encrypted] = stableStringify(changes);
|
||||
|
||||
transaction = {
|
||||
privacy: "private",
|
||||
@@ -427,7 +463,7 @@ export class CoValueCore {
|
||||
transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt,
|
||||
changes,
|
||||
changes: stableStringify(changes),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -450,13 +486,13 @@ export class CoValueCore {
|
||||
);
|
||||
|
||||
if (success) {
|
||||
void this.node.sync.syncCoValue(this);
|
||||
void this.node.syncManager.syncCoValue(this);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
getCurrentContent(): CoValueImpl {
|
||||
getCurrentContent(): CoValue {
|
||||
if (this._cachedContent) {
|
||||
return this._cachedContent;
|
||||
}
|
||||
@@ -471,8 +507,6 @@ export class CoValueCore {
|
||||
} else {
|
||||
this._cachedContent = new CoStream(this);
|
||||
}
|
||||
} else if (this.header.type === "static") {
|
||||
this._cachedContent = new Static(this);
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||
}
|
||||
@@ -497,10 +531,11 @@ export class CoValueCore {
|
||||
if (!readKey) {
|
||||
return undefined;
|
||||
} else {
|
||||
let decrytedChanges = this._decryptionCache[tx.encryptedChanges];
|
||||
let decrytedChanges =
|
||||
this._decryptionCache[tx.encryptedChanges];
|
||||
|
||||
if (!decrytedChanges) {
|
||||
decrytedChanges = decryptForTransaction(
|
||||
decrytedChanges = decryptRawForTransaction(
|
||||
tx.encryptedChanges,
|
||||
readKey,
|
||||
{
|
||||
@@ -508,7 +543,8 @@ export class CoValueCore {
|
||||
tx: txID,
|
||||
}
|
||||
);
|
||||
this._decryptionCache[tx.encryptedChanges] = decrytedChanges;
|
||||
this._decryptionCache[tx.encryptedChanges] =
|
||||
decrytedChanges;
|
||||
}
|
||||
|
||||
if (!decrytedChanges) {
|
||||
@@ -572,26 +608,24 @@ export class CoValueCore {
|
||||
|
||||
// Try to find key revelation for us
|
||||
|
||||
const readKeyEntry = content.getLastEntry(
|
||||
const lastReadyKeyEdit = content.lastEditAt(
|
||||
`${keyID}_for_${this.node.account.id}`
|
||||
);
|
||||
|
||||
if (readKeyEntry) {
|
||||
const revealer = accountOrAgentIDfromSessionID(
|
||||
readKeyEntry.txID.sessionID
|
||||
);
|
||||
if (lastReadyKeyEdit?.value) {
|
||||
const revealer = lastReadyKeyEdit.by;
|
||||
const revealerAgent = this.node.resolveAccountAgent(
|
||||
revealer,
|
||||
"Expected to know revealer"
|
||||
);
|
||||
|
||||
const secret = unseal(
|
||||
readKeyEntry.value,
|
||||
lastReadyKeyEdit.value,
|
||||
this.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: readKeyEntry.txID,
|
||||
tx: lastReadyKeyEdit.tx,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -680,47 +714,96 @@ export class CoValueCore {
|
||||
|
||||
newContentSince(
|
||||
knownState: CoValueKnownState | undefined
|
||||
): NewContentMessage | undefined {
|
||||
const newContent: NewContentMessage = {
|
||||
): NewContentMessage[] | undefined {
|
||||
let currentPiece: NewContentMessage = {
|
||||
action: "content",
|
||||
id: this.id,
|
||||
header: knownState?.header ? undefined : this.header,
|
||||
new: Object.fromEntries(
|
||||
Object.entries(this.sessions)
|
||||
.map(([sessionID, log]) => {
|
||||
const newTransactions = log.transactions.slice(
|
||||
knownState?.sessions[sessionID as SessionID] || 0
|
||||
);
|
||||
|
||||
if (
|
||||
newTransactions.length === 0 ||
|
||||
!log.lastHash ||
|
||||
!log.lastSignature
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
sessionID,
|
||||
{
|
||||
after:
|
||||
knownState?.sessions[
|
||||
sessionID as SessionID
|
||||
] || 0,
|
||||
newTransactions,
|
||||
lastSignature: log.lastSignature,
|
||||
},
|
||||
];
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x)
|
||||
),
|
||||
new: {},
|
||||
};
|
||||
|
||||
if (!newContent.header && Object.keys(newContent.new).length === 0) {
|
||||
const pieces = [currentPiece];
|
||||
|
||||
const sentState: CoValueKnownState["sessions"] = {
|
||||
...knownState?.sessions,
|
||||
};
|
||||
|
||||
let newTxsWereAdded = true;
|
||||
let pieceSize = 0;
|
||||
while (newTxsWereAdded) {
|
||||
newTxsWereAdded = false;
|
||||
|
||||
for (const [sessionID, log] of Object.entries(this.sessions) as [
|
||||
SessionID,
|
||||
SessionLog
|
||||
][]) {
|
||||
const nextKnownSignatureIdx = Object.keys(log.signatureAfter)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.find((idx) => idx >= (sentState[sessionID] ?? -1));
|
||||
|
||||
const txsToAdd = log.transactions.slice(
|
||||
sentState[sessionID] ?? 0,
|
||||
nextKnownSignatureIdx === undefined
|
||||
? undefined
|
||||
: nextKnownSignatureIdx + 1
|
||||
);
|
||||
|
||||
if (txsToAdd.length === 0) continue;
|
||||
|
||||
newTxsWereAdded = true;
|
||||
|
||||
const oldPieceSize = pieceSize;
|
||||
pieceSize += txsToAdd.reduce(
|
||||
(sum, tx) =>
|
||||
sum +
|
||||
(tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length),
|
||||
0
|
||||
);
|
||||
|
||||
if (pieceSize >= MAX_RECOMMENDED_TX_SIZE) {
|
||||
currentPiece = {
|
||||
action: "content",
|
||||
id: this.id,
|
||||
header: undefined,
|
||||
new: {},
|
||||
};
|
||||
pieces.push(currentPiece);
|
||||
pieceSize = pieceSize - oldPieceSize;
|
||||
}
|
||||
|
||||
let sessionEntry = currentPiece.new[sessionID];
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: sentState[sessionID] ?? 0,
|
||||
newTransactions: [],
|
||||
lastSignature: "WILL_BE_REPLACED" as Signature,
|
||||
};
|
||||
currentPiece.new[sessionID] = sessionEntry;
|
||||
}
|
||||
|
||||
sessionEntry.newTransactions.push(...txsToAdd);
|
||||
sessionEntry.lastSignature =
|
||||
nextKnownSignatureIdx === undefined
|
||||
? log.lastSignature!
|
||||
: log.signatureAfter[nextKnownSignatureIdx]!;
|
||||
|
||||
sentState[sessionID] =
|
||||
(sentState[sessionID] || 0) + txsToAdd.length;
|
||||
}
|
||||
}
|
||||
|
||||
const piecesWithContent = pieces.filter(
|
||||
(piece) => Object.keys(piece.new).length > 0 || piece.header
|
||||
);
|
||||
|
||||
if (piecesWithContent.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return newContent;
|
||||
return piecesWithContent;
|
||||
}
|
||||
|
||||
getDependedOnCoValues(): RawCoID[] {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { CoID, ReadableCoValue, WriteableCoValue } from "../coValue.js";
|
||||
import { CoID, CoValue, isCoValue } from "../coValue.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { SessionID, TransactionID } from "../ids.js";
|
||||
import { AgentID, SessionID, TransactionID } from "../ids.js";
|
||||
import { Group } from "../group.js";
|
||||
import { AccountID, isAccountID } from "../account.js";
|
||||
import { AccountID } from "../account.js";
|
||||
import { parseJSON } from "../jsonStringify.js";
|
||||
|
||||
type OpID = TransactionID & { changeIdx: number };
|
||||
|
||||
type InsertionOpPayload<T extends JsonValue> =
|
||||
type InsertionOpPayload<T extends JsonValue | CoValue> =
|
||||
| {
|
||||
op: "pre";
|
||||
value: T;
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
||||
before: OpID | "end";
|
||||
}
|
||||
| {
|
||||
op: "app";
|
||||
value: T;
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
||||
after: OpID | "start";
|
||||
};
|
||||
|
||||
@@ -24,11 +25,11 @@ type DeletionOpPayload = {
|
||||
insertion: OpID;
|
||||
};
|
||||
|
||||
export type ListOpPayload<T extends JsonValue> =
|
||||
export type ListOpPayload<T extends JsonValue | CoValue> =
|
||||
| InsertionOpPayload<T>
|
||||
| DeletionOpPayload;
|
||||
|
||||
type InsertionEntry<T extends JsonValue> = {
|
||||
type InsertionEntry<T extends JsonValue | CoValue> = {
|
||||
madeAt: number;
|
||||
predecessors: OpID[];
|
||||
successors: OpID[];
|
||||
@@ -39,11 +40,16 @@ type DeletionEntry = {
|
||||
deletionID: OpID;
|
||||
} & DeletionOpPayload;
|
||||
|
||||
export class CoList<T extends JsonValue, Meta extends JsonObject | null = null>
|
||||
implements ReadableCoValue
|
||||
export class CoListView<
|
||||
Item extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
> implements CoValue
|
||||
{
|
||||
id: CoID<CoList<T, Meta>>;
|
||||
/** @category 6. Meta */
|
||||
id: CoID<this>;
|
||||
/** @category 6. Meta */
|
||||
type = "colist" as const;
|
||||
/** @category 6. Meta */
|
||||
core: CoValueCore;
|
||||
/** @internal */
|
||||
afterStart: OpID[];
|
||||
@@ -53,7 +59,7 @@ export class CoList<T extends JsonValue, Meta extends JsonObject | null = null>
|
||||
insertions: {
|
||||
[sessionID: SessionID]: {
|
||||
[txIdx: number]: {
|
||||
[changeIdx: number]: InsertionEntry<T>;
|
||||
[changeIdx: number]: InsertionEntry<Item>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -65,29 +71,18 @@ export class CoList<T extends JsonValue, Meta extends JsonObject | null = null>
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @category 6. Meta */
|
||||
readonly _item!: Item;
|
||||
|
||||
/** @internal */
|
||||
constructor(core: CoValueCore) {
|
||||
this.id = core.id as CoID<CoList<T, Meta>>;
|
||||
this.id = core.id as CoID<this>;
|
||||
this.core = core;
|
||||
this.afterStart = [];
|
||||
this.beforeEnd = [];
|
||||
this.insertions = {};
|
||||
this.deletionsByInsertion = {};
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
}
|
||||
|
||||
get meta(): Meta {
|
||||
return this.core.header.meta as Meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.core.getGroup();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
protected fillOpsFromCoValue() {
|
||||
this.insertions = {};
|
||||
this.deletionsByInsertion = {};
|
||||
this.afterStart = [];
|
||||
@@ -98,8 +93,10 @@ export class CoList<T extends JsonValue, Meta extends JsonObject | null = null>
|
||||
changes,
|
||||
madeAt,
|
||||
} of this.core.getValidSortedTransactions()) {
|
||||
for (const [changeIdx, changeUntyped] of changes.entries()) {
|
||||
const change = changeUntyped as ListOpPayload<T>;
|
||||
for (const [changeIdx, changeUntyped] of parseJSON(
|
||||
changes
|
||||
).entries()) {
|
||||
const change = changeUntyped as ListOpPayload<Item>;
|
||||
|
||||
if (change.op === "pre" || change.op === "app") {
|
||||
let sessionEntry = this.insertions[txID.sessionID];
|
||||
@@ -199,8 +196,35 @@ export class CoList<T extends JsonValue, Meta extends JsonObject | null = null>
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the item currently at `idx`. */
|
||||
get(idx: number): T | undefined {
|
||||
/** @category 6. Meta */
|
||||
get meta(): Meta {
|
||||
return this.core.header.meta as Meta;
|
||||
}
|
||||
|
||||
/** @category 6. Meta */
|
||||
get group(): Group {
|
||||
return this.core.getGroup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Not yet implemented
|
||||
*
|
||||
* @category 4. Time travel
|
||||
*/
|
||||
atTime(_time: number): this {
|
||||
throw new Error("Not yet implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the item currently at `idx`.
|
||||
*
|
||||
* @category 1. Reading
|
||||
*/
|
||||
get(
|
||||
idx: number
|
||||
):
|
||||
| (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)
|
||||
| undefined {
|
||||
const entry = this.entries()[idx];
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
@@ -208,13 +232,26 @@ export class CoList<T extends JsonValue, Meta extends JsonObject | null = null>
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/** Returns the current items in the CoList as an array. */
|
||||
asArray(): T[] {
|
||||
/**
|
||||
* Returns the current items in the CoList as an array.
|
||||
*
|
||||
* @category 1. Reading
|
||||
**/
|
||||
asArray(): (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)[] {
|
||||
return this.entries().map((entry) => entry.value);
|
||||
}
|
||||
|
||||
entries(): { value: T; madeAt: number; opID: OpID }[] {
|
||||
const arr: { value: T; madeAt: number; opID: OpID }[] = [];
|
||||
/** @internal */
|
||||
entries(): {
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
madeAt: number;
|
||||
opID: OpID;
|
||||
}[] {
|
||||
const arr: {
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
madeAt: number;
|
||||
opID: OpID;
|
||||
}[] = [];
|
||||
for (const opID of this.afterStart) {
|
||||
this.fillArrayFromOpID(opID, arr);
|
||||
}
|
||||
@@ -227,7 +264,11 @@ export class CoList<T extends JsonValue, Meta extends JsonObject | null = null>
|
||||
/** @internal */
|
||||
private fillArrayFromOpID(
|
||||
opID: OpID,
|
||||
arr: { value: T; madeAt: number; opID: OpID }[]
|
||||
arr: {
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
madeAt: number;
|
||||
opID: OpID;
|
||||
}[]
|
||||
) {
|
||||
const entry =
|
||||
this.insertions[opID.sessionID]?.[opID.txIndex]?.[opID.changeIdx];
|
||||
@@ -253,86 +294,115 @@ export class CoList<T extends JsonValue, Meta extends JsonObject | null = null>
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the accountID of the account that inserted value at the given index. */
|
||||
whoInserted(idx: number): AccountID | undefined {
|
||||
/**
|
||||
* Returns the current items in the CoList as an array. (alias of `asArray`)
|
||||
*
|
||||
* @category 1. Reading
|
||||
*/
|
||||
toJSON(): (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)[] {
|
||||
return this.asArray();
|
||||
}
|
||||
|
||||
/** @category 5. Edit history */
|
||||
editAt(idx: number):
|
||||
| {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
}
|
||||
| undefined {
|
||||
const entry = this.entries()[idx];
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
const accountID = accountOrAgentIDfromSessionID(entry.opID.sessionID);
|
||||
if (isAccountID(accountID)) {
|
||||
return accountID;
|
||||
} else {
|
||||
return undefined;
|
||||
const madeAt = new Date(entry.madeAt);
|
||||
const by = accountOrAgentIDfromSessionID(entry.opID.sessionID);
|
||||
const value = entry.value;
|
||||
return {
|
||||
by,
|
||||
tx: {
|
||||
sessionID: entry.opID.sessionID,
|
||||
txIndex: entry.opID.txIndex,
|
||||
},
|
||||
at: madeAt,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
/** @category 5. Edit history */
|
||||
deletionEdits(): {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
// TODO: add indices that are now before and after the deleted item
|
||||
}[] {
|
||||
const edits: {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
}[] = [];
|
||||
|
||||
for (const sessionID in this.deletionsByInsertion) {
|
||||
const sessionEntry =
|
||||
this.deletionsByInsertion[sessionID as SessionID];
|
||||
for (const txIdx in sessionEntry) {
|
||||
const txEntry = sessionEntry[Number(txIdx)];
|
||||
for (const changeIdx in txEntry) {
|
||||
const changeEntry = txEntry[Number(changeIdx)];
|
||||
for (const deletion of changeEntry || []) {
|
||||
const madeAt = new Date(deletion.madeAt);
|
||||
const by = accountOrAgentIDfromSessionID(
|
||||
deletion.deletionID.sessionID
|
||||
);
|
||||
edits.push({
|
||||
by,
|
||||
tx: deletion.deletionID,
|
||||
at: madeAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edits;
|
||||
}
|
||||
|
||||
/** Returns the current items in the CoList as an array. (alias of `asArray`) */
|
||||
toJSON(): T[] {
|
||||
return this.asArray();
|
||||
}
|
||||
|
||||
map<U>(mapper: (value: T, idx: number) => U): U[] {
|
||||
return this.entries().map((entry, idx) => mapper(entry.value, idx));
|
||||
}
|
||||
|
||||
filter<U extends T>(predicate: (value: T, idx: number) => value is U): U[];
|
||||
filter(predicate: (value: T, idx: number) => boolean): T[] {
|
||||
return this.entries()
|
||||
.filter((entry, idx) => predicate(entry.value, idx))
|
||||
.map((entry) => entry.value);
|
||||
}
|
||||
|
||||
reduce<U>(
|
||||
reducer: (accumulator: U, value: T, idx: number) => U,
|
||||
initialValue: U
|
||||
): U {
|
||||
return this.entries().reduce(
|
||||
(accumulator, entry, idx) => reducer(accumulator, entry.value, idx),
|
||||
initialValue
|
||||
);
|
||||
}
|
||||
|
||||
subscribe(listener: (coMap: CoList<T, Meta>) => void): () => void {
|
||||
/** @category 3. Subscription */
|
||||
subscribe(listener: (coList: this) => void): () => void {
|
||||
return this.core.subscribe((content) => {
|
||||
listener(content as CoList<T, Meta>);
|
||||
listener(content as this);
|
||||
});
|
||||
}
|
||||
|
||||
edit(
|
||||
changer: (editable: WriteableCoList<T, Meta>) => void
|
||||
): CoList<T, Meta> {
|
||||
const editable = new WriteableCoList<T, Meta>(this.core);
|
||||
changer(editable);
|
||||
return new CoList(this.core);
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableCoList<
|
||||
T extends JsonValue,
|
||||
export class CoList<
|
||||
Item extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
>
|
||||
extends CoList<T, Meta>
|
||||
implements WriteableCoValue
|
||||
extends CoListView<Item, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
/** @internal */
|
||||
edit(
|
||||
_changer: (editable: WriteableCoList<T, Meta>) => void
|
||||
): CoList<T, Meta> {
|
||||
throw new Error("Already editing.");
|
||||
}
|
||||
|
||||
/** Appends a new item after index `after`.
|
||||
/** Returns a new version of this CoList with `item` appended after the item currently at index `after`.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, both `value` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
* If `privacy` is `"private"` **(default)**, `item` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
|
||||
* If `privacy` is `"trusting"`, `item` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
*
|
||||
* @category 2. Editing
|
||||
**/
|
||||
append(
|
||||
after: number,
|
||||
value: T,
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
after?: number,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
): this {
|
||||
const entries = this.entries();
|
||||
after =
|
||||
after === undefined
|
||||
? entries.length > 0
|
||||
? entries.length - 1
|
||||
: 0
|
||||
: 0;
|
||||
let opIDBefore;
|
||||
if (entries.length > 0) {
|
||||
const entryBefore = entries[after];
|
||||
@@ -350,44 +420,32 @@ export class WriteableCoList<
|
||||
[
|
||||
{
|
||||
op: "app",
|
||||
value,
|
||||
value: isCoValue(item) ? item.id : item,
|
||||
after: opIDBefore,
|
||||
},
|
||||
],
|
||||
privacy
|
||||
);
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
}
|
||||
|
||||
/** Pushes a new item to the end of the list.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, both `value` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
|
||||
push(value: T, privacy: "private" | "trusting" = "private"): void {
|
||||
// TODO: optimize
|
||||
const entries = this.entries();
|
||||
this.append(
|
||||
entries.length > 0 ? entries.length - 1 : 0,
|
||||
value,
|
||||
privacy
|
||||
);
|
||||
return new CoList(this.core) as this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepends a new item before index `before`.
|
||||
* Returns a new version of this CoList with `item` prepended before the item currently at index `before`.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, both `value` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
* If `privacy` is `"private"` **(default)**, `item` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
* If `privacy` is `"trusting"`, `item` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
*
|
||||
* @category 2. Editing
|
||||
*/
|
||||
prepend(
|
||||
before: number,
|
||||
value: T,
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
before?: number,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
): this {
|
||||
const entries = this.entries();
|
||||
before = before === undefined ? 0 : before;
|
||||
let opIDAfter;
|
||||
if (entries.length > 0) {
|
||||
const entryAfter = entries[before];
|
||||
@@ -409,22 +467,25 @@ export class WriteableCoList<
|
||||
[
|
||||
{
|
||||
op: "pre",
|
||||
value,
|
||||
value: isCoValue(item) ? item.id : item,
|
||||
before: opIDAfter,
|
||||
},
|
||||
],
|
||||
privacy
|
||||
);
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
return new CoList(this.core) as this;
|
||||
}
|
||||
|
||||
/** Deletes the item at index `at` from the list.
|
||||
/** Returns a new version of this CoList with the item at index `at` deleted from the list.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, the fact of this deletion is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, the fact of this deletion is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
|
||||
delete(at: number, privacy: "private" | "trusting" = "private"): void {
|
||||
* If `privacy` is `"trusting"`, the fact of this deletion is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
*
|
||||
* @category 2. Editing
|
||||
**/
|
||||
delete(at: number, privacy: "private" | "trusting" = "private"): this {
|
||||
const entries = this.entries();
|
||||
const entry = entries[at];
|
||||
if (!entry) {
|
||||
@@ -440,6 +501,96 @@ export class WriteableCoList<
|
||||
privacy
|
||||
);
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
return new CoList(this.core) as this;
|
||||
}
|
||||
|
||||
/** @category 2. Editing */
|
||||
mutate(mutator: (mutable: MutableCoList<Item, Meta>) => void): this {
|
||||
const mutable = new MutableCoList<Item, Meta>(this.core);
|
||||
mutator(mutable);
|
||||
return new CoList(this.core) as this;
|
||||
}
|
||||
|
||||
/** @deprecated Use `mutate` instead. */
|
||||
edit(mutator: (mutable: MutableCoList<Item, Meta>) => void): this {
|
||||
return this.mutate(mutator);
|
||||
}
|
||||
}
|
||||
|
||||
export class MutableCoList<
|
||||
Item extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
>
|
||||
extends CoListView<Item, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
/** Appends `item` after the item currently at index `after`.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, `item` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, `item` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
*
|
||||
* @category 2. Mutating
|
||||
**/
|
||||
append(
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
after?: number,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
const listAfter = CoList.prototype.append.call(
|
||||
this,
|
||||
item,
|
||||
after,
|
||||
privacy
|
||||
) as CoList<Item, Meta>;
|
||||
this.afterStart = listAfter.afterStart;
|
||||
this.beforeEnd = listAfter.beforeEnd;
|
||||
this.insertions = listAfter.insertions;
|
||||
this.deletionsByInsertion = listAfter.deletionsByInsertion;
|
||||
}
|
||||
|
||||
/** Prepends `item` before the item currently at index `before`.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, `item` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, `item` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
*
|
||||
* * @category 2. Mutating
|
||||
**/
|
||||
prepend(
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
before?: number,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
const listAfter = CoList.prototype.prepend.call(
|
||||
this,
|
||||
item,
|
||||
before,
|
||||
privacy
|
||||
) as CoList<Item, Meta>;
|
||||
this.afterStart = listAfter.afterStart;
|
||||
this.beforeEnd = listAfter.beforeEnd;
|
||||
this.insertions = listAfter.insertions;
|
||||
this.deletionsByInsertion = listAfter.deletionsByInsertion;
|
||||
}
|
||||
|
||||
/** Deletes the item at index `at` from the list.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, the fact of this deletion is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, the fact of this deletion is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
*
|
||||
* * @category 2. Mutating
|
||||
**/
|
||||
delete(at: number, privacy: "private" | "trusting" = "private"): void {
|
||||
const listAfter = CoList.prototype.delete.call(
|
||||
this,
|
||||
at,
|
||||
privacy
|
||||
) as CoList<Item, Meta>;
|
||||
this.afterStart = listAfter.afterStart;
|
||||
this.beforeEnd = listAfter.beforeEnd;
|
||||
this.insertions = listAfter.insertions;
|
||||
this.deletionsByInsertion = listAfter.deletionsByInsertion;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,70 @@
|
||||
import { JsonObject, JsonValue } from '../jsonValue.js';
|
||||
import { TransactionID } from '../ids.js';
|
||||
import { CoID, ReadableCoValue, WriteableCoValue } from '../coValue.js';
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from '../coValueCore.js';
|
||||
import { AccountID, isAccountID } from '../account.js';
|
||||
import { Group } from '../group.js';
|
||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { AgentID, TransactionID } from "../ids.js";
|
||||
import { CoID, CoValue, isCoValue } from "../coValue.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { AccountID } from "../account.js";
|
||||
import { Group } from "../group.js";
|
||||
import { parseJSON } from "../jsonStringify.js";
|
||||
|
||||
type MapOp<K extends string, V extends JsonValue> = {
|
||||
type MapOp<K extends string, V extends JsonValue | CoValue | undefined> = {
|
||||
txID: TransactionID;
|
||||
madeAt: number;
|
||||
changeIdx: number;
|
||||
} & MapOpPayload<K, V>;
|
||||
// TODO: add after TransactionID[] for conflicts/ordering
|
||||
|
||||
export type MapOpPayload<K extends string, V extends JsonValue> = {
|
||||
op: "set";
|
||||
key: K;
|
||||
value: V;
|
||||
} |
|
||||
export type MapOpPayload<
|
||||
K extends string,
|
||||
V extends JsonValue | CoValue | undefined
|
||||
> =
|
||||
| {
|
||||
op: "set";
|
||||
key: K;
|
||||
value: V extends CoValue ? CoID<V> : Exclude<V, CoValue>;
|
||||
}
|
||||
| {
|
||||
op: "del";
|
||||
key: K;
|
||||
};
|
||||
|
||||
export class CoMapView<
|
||||
Shape extends { [key: string]: JsonValue | CoValue | undefined },
|
||||
Meta extends JsonObject | null = null
|
||||
> implements CoValue
|
||||
{
|
||||
op: "del";
|
||||
key: K;
|
||||
};
|
||||
|
||||
export type MapK<M extends { [key: string]: JsonValue; }> = keyof M & string;
|
||||
export type MapV<M extends { [key: string]: JsonValue; }> = M[MapK<M>];
|
||||
export type MapM<M extends { [key: string]: JsonValue; }> = {
|
||||
[KK in MapK<M>]: M[KK];
|
||||
}
|
||||
|
||||
/** A collaborative map with precise shape `M` and optional static metadata `Meta` */
|
||||
export class CoMap<
|
||||
M extends { [key: string]: JsonValue; },
|
||||
Meta extends JsonObject | null = null,
|
||||
> implements ReadableCoValue {
|
||||
id: CoID<CoMap<MapM<M>, Meta>>;
|
||||
/** @category 6. Meta */
|
||||
id: CoID<this>;
|
||||
/** @category 6. Meta */
|
||||
type = "comap" as const;
|
||||
/** @category 6. Meta */
|
||||
core: CoValueCore;
|
||||
/** @internal */
|
||||
ops: {
|
||||
[KK in MapK<M>]?: MapOp<KK, M[KK]>[];
|
||||
[Key in keyof Shape & string]?: MapOp<Key, Shape[Key]>[];
|
||||
};
|
||||
/** @internal */
|
||||
atTimeFilter?: number = undefined;
|
||||
/** @category 6. Meta */
|
||||
readonly _shape!: Shape;
|
||||
|
||||
/** @internal */
|
||||
constructor(core: CoValueCore) {
|
||||
this.id = core.id as CoID<CoMap<MapM<M>, Meta>>;
|
||||
this.id = core.id as CoID<this>;
|
||||
this.core = core;
|
||||
this.ops = {};
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
}
|
||||
|
||||
get meta(): Meta {
|
||||
return this.core.header.meta as Meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.core.getGroup();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
protected fillOpsFromCoValue() {
|
||||
this.ops = {};
|
||||
|
||||
for (const { txID, changes, madeAt } of this.core.getValidSortedTransactions()) {
|
||||
for (const [changeIdx, changeUntyped] of (
|
||||
for (const {
|
||||
txID,
|
||||
changes,
|
||||
madeAt,
|
||||
} of core.getValidSortedTransactions()) {
|
||||
for (const [changeIdx, changeUntyped] of parseJSON(
|
||||
changes
|
||||
).entries()) {
|
||||
const change = changeUntyped as MapOpPayload<MapK<M>, MapV<M>>;
|
||||
const change = changeUntyped as MapOpPayload<
|
||||
keyof Shape & string,
|
||||
Shape[keyof Shape & string]
|
||||
>;
|
||||
let entries = this.ops[change.key];
|
||||
if (!entries) {
|
||||
entries = [];
|
||||
@@ -76,175 +74,373 @@ export class CoMap<
|
||||
txID,
|
||||
madeAt,
|
||||
changeIdx,
|
||||
...(change as MapOpPayload<MapK<M>, MapV<M>>),
|
||||
...(change as MapOpPayload<
|
||||
keyof Shape & string,
|
||||
Shape[keyof Shape & string]
|
||||
>),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys(): MapK<M>[] {
|
||||
return Object.keys(this.ops) as MapK<M>[];
|
||||
/** @category 6. Meta */
|
||||
get meta(): Meta {
|
||||
return this.core.header.meta as Meta;
|
||||
}
|
||||
|
||||
/** Returns the current value for the given key. */
|
||||
get<K extends MapK<M>>(key: K): M[K] | undefined {
|
||||
const ops = this.ops[key];
|
||||
/** @category 6. Meta */
|
||||
get group(): Group {
|
||||
return this.core.getGroup();
|
||||
}
|
||||
|
||||
/** @category 4. Time travel */
|
||||
atTime(time: number): this {
|
||||
const clone = Object.create(this) as this;
|
||||
clone.id = this.id;
|
||||
clone.type = this.type;
|
||||
clone.core = this.core;
|
||||
clone.ops = this.ops;
|
||||
clone.atTimeFilter = time;
|
||||
return clone;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
timeFilteredOps<K extends keyof Shape & string>(
|
||||
key: K
|
||||
): MapOp<K, Shape[K]>[] | undefined {
|
||||
if (this.atTimeFilter) {
|
||||
return this.ops[key]?.filter(
|
||||
(op) => op.madeAt <= this.atTimeFilter!
|
||||
);
|
||||
} else {
|
||||
return this.ops[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys currently in the map.
|
||||
*
|
||||
* @category 1. Reading */
|
||||
keys(): (keyof Shape & string)[] {
|
||||
const keys = Object.keys(this.ops) as (keyof Shape & string)[];
|
||||
|
||||
if (this.atTimeFilter) {
|
||||
return keys.filter((key) => {
|
||||
this.timeFilteredOps(key)?.length;
|
||||
});
|
||||
} else {
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current value for the given key.
|
||||
*
|
||||
* @category 1. Reading
|
||||
**/
|
||||
get<K extends keyof Shape & string>(
|
||||
key: K
|
||||
):
|
||||
| (Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>)
|
||||
| undefined {
|
||||
const ops = this.timeFilteredOps(key);
|
||||
if (!ops) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastEntry = ops[ops.length - 1]!;
|
||||
const includeUntil = this.atTimeFilter;
|
||||
const lastEntry = includeUntil
|
||||
? ops.findLast((entry) => entry.madeAt <= includeUntil)
|
||||
: ops[ops.length - 1]!;
|
||||
|
||||
if (lastEntry.op === "del") {
|
||||
if (lastEntry?.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return lastEntry.value;
|
||||
return lastEntry?.value;
|
||||
}
|
||||
}
|
||||
|
||||
getAtTime<K extends MapK<M>>(key: K, time: number): M[K] | undefined {
|
||||
const ops = this.ops[key];
|
||||
if (!ops) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastOpBeforeOrAtTime = ops.findLast((op) => op.madeAt <= time);
|
||||
|
||||
if (!lastOpBeforeOrAtTime) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastOpBeforeOrAtTime.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return lastOpBeforeOrAtTime.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the accountID of the last account to modify the value for the given key. */
|
||||
whoEdited<K extends MapK<M>>(key: K): AccountID | undefined {
|
||||
const tx = this.getLastTxID(key);
|
||||
if (!tx) {
|
||||
return undefined;
|
||||
}
|
||||
const accountID = accountOrAgentIDfromSessionID(tx.sessionID);
|
||||
if (isAccountID(accountID)) {
|
||||
return accountID;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getLastTxID<K extends MapK<M>>(key: K): TransactionID | undefined {
|
||||
const ops = this.ops[key];
|
||||
if (!ops) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastEntry = ops[ops.length - 1]!;
|
||||
|
||||
return lastEntry.txID;
|
||||
}
|
||||
|
||||
getLastEntry<K extends MapK<M>>(key: K): { at: number; txID: TransactionID; value: M[K]; } | undefined {
|
||||
const ops = this.ops[key];
|
||||
if (!ops) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastEntry = ops[ops.length - 1]!;
|
||||
|
||||
if (lastEntry.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return { at: lastEntry.madeAt, txID: lastEntry.txID, value: lastEntry.value };
|
||||
}
|
||||
}
|
||||
|
||||
getHistory<K extends MapK<M>>(key: K): { at: number; txID: TransactionID; value: M[K] | undefined; }[] {
|
||||
const ops = this.ops[key];
|
||||
if (!ops) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const history: { at: number; txID: TransactionID; value: M[K] | undefined; }[] = [];
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.op === "del") {
|
||||
history.push({ at: op.madeAt, txID: op.txID, value: undefined });
|
||||
} else {
|
||||
history.push({ at: op.madeAt, txID: op.txID, value: op.value });
|
||||
}
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
toJSON(): JsonObject {
|
||||
const json: JsonObject = {};
|
||||
/** @category 1. Reading */
|
||||
asObject(): {
|
||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
} {
|
||||
const object: Partial<{
|
||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
}> = {};
|
||||
|
||||
for (const key of this.keys()) {
|
||||
const value = this.get(key);
|
||||
if (value !== undefined) {
|
||||
json[key] = value;
|
||||
object[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return json;
|
||||
return object as {
|
||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(listener: (coMap: CoMap<M, Meta>) => void): () => void {
|
||||
/** @category 1. Reading */
|
||||
toJSON(): {
|
||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
} {
|
||||
return this.asObject();
|
||||
}
|
||||
|
||||
/** @category 5. Edit history */
|
||||
nthEditAt<K extends keyof Shape & string>(
|
||||
key: K,
|
||||
n: number
|
||||
):
|
||||
| {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value?: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
}
|
||||
| undefined {
|
||||
const ops = this.timeFilteredOps(key);
|
||||
if (!ops || ops.length <= n) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entry = ops[n]!;
|
||||
|
||||
if (this.atTimeFilter && entry.madeAt > this.atTimeFilter) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
by: accountOrAgentIDfromSessionID(entry.txID.sessionID),
|
||||
tx: entry.txID,
|
||||
at: new Date(entry.madeAt),
|
||||
value: entry.op === "del" ? undefined : entry.value,
|
||||
};
|
||||
}
|
||||
|
||||
/** @category 5. Edit history */
|
||||
lastEditAt<K extends keyof Shape & string>(
|
||||
key: K
|
||||
):
|
||||
| {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value?: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
}
|
||||
| undefined {
|
||||
const ops = this.timeFilteredOps(key);
|
||||
if (!ops || ops.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return this.nthEditAt(key, ops.length - 1);
|
||||
}
|
||||
|
||||
/** @category 5. Edit history */
|
||||
*editsAt<K extends keyof Shape & string>(key: K) {
|
||||
const ops = this.timeFilteredOps(key);
|
||||
if (!ops) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < ops.length; i++) {
|
||||
yield this.nthEditAt(key, i)!;
|
||||
}
|
||||
}
|
||||
|
||||
/** @category 3. Subscription */
|
||||
subscribe(listener: (coMap: this) => void): () => void {
|
||||
return this.core.subscribe((content) => {
|
||||
listener(content as CoMap<M, Meta>);
|
||||
listener(content as this);
|
||||
});
|
||||
}
|
||||
|
||||
edit(changer: (editable: WriteableCoMap<M, Meta>) => void): CoMap<M, Meta> {
|
||||
const editable = new WriteableCoMap<M, Meta>(this.core);
|
||||
changer(editable);
|
||||
return new CoMap(this.core);
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableCoMap<
|
||||
M extends { [key: string]: JsonValue; },
|
||||
Meta extends JsonObject | null = null,
|
||||
> extends CoMap<M, Meta> implements WriteableCoValue {
|
||||
/** @internal */
|
||||
edit(_changer: (editable: WriteableCoMap<M, Meta>) => void): CoMap<M, Meta> {
|
||||
throw new Error("Already editing.");
|
||||
/** A collaborative map with precise shape `Shape` and optional static metadata `Meta` */
|
||||
export class CoMap<
|
||||
Shape extends { [key: string]: JsonValue | CoValue | undefined },
|
||||
Meta extends JsonObject | null = null
|
||||
>
|
||||
extends CoMapView<Shape, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
|
||||
|
||||
/** Returns a new version of this CoMap with a new value for the given key.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, both `key` and `value` are encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, both `key` and `value` are stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
*
|
||||
* @category 2. Editing
|
||||
**/
|
||||
set<K extends keyof Shape & string>(
|
||||
key: K,
|
||||
value: Shape[K] extends CoValue ? Shape[K] | CoID<Shape[K]> : Shape[K],
|
||||
privacy?: "private" | "trusting"
|
||||
): this;
|
||||
set(
|
||||
kv: {
|
||||
[K in keyof Shape & string]?: Shape[K] extends CoValue
|
||||
? Shape[K] | CoID<Shape[K]>
|
||||
: Shape[K];
|
||||
},
|
||||
privacy?: "private" | "trusting"
|
||||
): this;
|
||||
set<K extends keyof Shape & string>(
|
||||
...args:
|
||||
| [
|
||||
{
|
||||
[K in keyof Shape & string]?: Shape[K] extends CoValue
|
||||
? Shape[K] | CoID<Shape[K]>
|
||||
: Shape[K];
|
||||
},
|
||||
("private" | "trusting")?
|
||||
]
|
||||
| [
|
||||
K,
|
||||
Shape[K] extends CoValue
|
||||
? Shape[K] | CoID<Shape[K]>
|
||||
: Shape[K],
|
||||
("private" | "trusting")?
|
||||
]
|
||||
): this {
|
||||
if (typeof args[0] === "string") {
|
||||
const [key, value, privacy = "private"] = args;
|
||||
this.core.makeTransaction(
|
||||
[
|
||||
{
|
||||
op: "set",
|
||||
key,
|
||||
value: isCoValue(value) ? value.id : value,
|
||||
},
|
||||
],
|
||||
privacy
|
||||
);
|
||||
} else {
|
||||
const [kv, privacy = "private"] = args as [
|
||||
{
|
||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
||||
? Shape[K] | CoID<Shape[K]>
|
||||
: Shape[K];
|
||||
},
|
||||
"private" | "trusting" | undefined
|
||||
];
|
||||
|
||||
for (const [key, value] of Object.entries(kv)) {
|
||||
this.core.makeTransaction(
|
||||
[
|
||||
{
|
||||
op: "set",
|
||||
key,
|
||||
value: isCoValue(value) ? value.id : value,
|
||||
},
|
||||
],
|
||||
privacy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new CoMap(this.core) as this;
|
||||
}
|
||||
|
||||
/** Returns a new version of this CoMap with the given key deleted (setting it to undefined).
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, `key` is encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, `key` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
*
|
||||
* @category 2. Editing
|
||||
**/
|
||||
delete(
|
||||
key: keyof Shape & string,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): this {
|
||||
this.core.makeTransaction(
|
||||
[
|
||||
{
|
||||
op: "del",
|
||||
key,
|
||||
},
|
||||
],
|
||||
privacy
|
||||
);
|
||||
|
||||
return new CoMap(this.core) as this;
|
||||
}
|
||||
|
||||
/** @category 2. Editing */
|
||||
mutate(mutator: (mutable: MutableCoMap<Shape, Meta>) => void): this {
|
||||
const mutable = new MutableCoMap<Shape, Meta>(this.core);
|
||||
mutator(mutable);
|
||||
return new CoMap(this.core) as this;
|
||||
}
|
||||
|
||||
/** @deprecated Use `mutate` instead. */
|
||||
edit(mutator: (mutable: MutableCoMap<Shape, Meta>) => void): this {
|
||||
return this.mutate(mutator);
|
||||
}
|
||||
}
|
||||
|
||||
export class MutableCoMap<
|
||||
Shape extends { [key: string]: JsonValue | CoValue | undefined },
|
||||
Meta extends JsonObject | null = null
|
||||
>
|
||||
extends CoMapView<Shape, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
/** Sets a new value for the given key.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, both `key` and `value` are encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, both `key` and `value` are stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
|
||||
set<K extends MapK<M>>(key: K, value: M[K], privacy: "private" | "trusting" = "private"): void {
|
||||
this.core.makeTransaction([
|
||||
{
|
||||
op: "set",
|
||||
key,
|
||||
value,
|
||||
},
|
||||
], privacy);
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
* If `privacy` is `"trusting"`, both `key` and `value` are stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
*
|
||||
* @category 2. Mutation
|
||||
*/
|
||||
set<K extends keyof Shape & string>(
|
||||
key: K,
|
||||
value: Shape[K] extends CoValue ? Shape[K] | CoID<Shape[K]> : Shape[K],
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
const after = (CoMap.prototype.set as Function).call(
|
||||
this,
|
||||
key,
|
||||
value,
|
||||
privacy
|
||||
) as CoMap<Shape, Meta>;
|
||||
this.ops = after.ops;
|
||||
}
|
||||
|
||||
/** Deletes the value for the given key (setting it to undefined).
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, `key` is encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, `key` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
|
||||
delete(key: MapK<M>, privacy: "private" | "trusting" = "private"): void {
|
||||
this.core.makeTransaction([
|
||||
{
|
||||
op: "del",
|
||||
key,
|
||||
},
|
||||
], privacy);
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
* If `privacy` is `"trusting"`, `key` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
* @category 2. Mutation
|
||||
*/
|
||||
delete(
|
||||
key: keyof Shape & string,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
const after = CoMap.prototype.delete.call(this, key, privacy) as CoMap<
|
||||
Shape,
|
||||
Meta
|
||||
>;
|
||||
this.ops = after.ops;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { CoID, ReadableCoValue, WriteableCoValue } from "../coValue.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { CoValue, CoID, isCoValue } from "../coValue.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { Group } from "../group.js";
|
||||
import { SessionID } from "../ids.js";
|
||||
import { AgentID, SessionID, TransactionID } from "../ids.js";
|
||||
import { base64URLtoBytes, bytesToBase64url } from "../base64url.js";
|
||||
import { AccountID } from "../account.js";
|
||||
import { parseJSON } from "../jsonStringify.js";
|
||||
|
||||
export type BinaryChunkInfo = {
|
||||
export type BinaryStreamInfo = {
|
||||
mimeType: string;
|
||||
fileName?: string;
|
||||
totalSizeBytes?: number;
|
||||
@@ -13,7 +15,7 @@ export type BinaryChunkInfo = {
|
||||
|
||||
export type BinaryStreamStart = {
|
||||
type: "start";
|
||||
} & BinaryChunkInfo;
|
||||
} & BinaryStreamInfo;
|
||||
|
||||
export type BinaryStreamChunk = {
|
||||
type: "chunk";
|
||||
@@ -31,20 +33,27 @@ export type BinaryStreamItem =
|
||||
| BinaryStreamChunk
|
||||
| BinaryStreamEnd;
|
||||
|
||||
export class CoStream<
|
||||
T extends JsonValue,
|
||||
export type CoStreamItem<Item extends JsonValue | CoValue> = {
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
tx: TransactionID;
|
||||
madeAt: number;
|
||||
};
|
||||
|
||||
export class CoStreamView<
|
||||
Item extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
> implements ReadableCoValue
|
||||
> implements CoValue
|
||||
{
|
||||
id: CoID<CoStream<T, Meta>>;
|
||||
id: CoID<this>;
|
||||
type = "costream" as const;
|
||||
core: CoValueCore;
|
||||
items: {
|
||||
[key: SessionID]: T[];
|
||||
[key: SessionID]: CoStreamItem<Item>[];
|
||||
};
|
||||
readonly _item!: Item;
|
||||
|
||||
constructor(core: CoValueCore) {
|
||||
this.id = core.id as CoID<CoStream<T, Meta>>;
|
||||
this.id = core.id as CoID<this>;
|
||||
this.core = core;
|
||||
this.items = {};
|
||||
this.fillFromCoValue();
|
||||
@@ -58,27 +67,37 @@ export class CoStream<
|
||||
return this.core.getGroup();
|
||||
}
|
||||
|
||||
/** Not yet implemented */
|
||||
atTime(_time: number): this {
|
||||
throw new Error("Not yet implemented");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
protected fillFromCoValue() {
|
||||
this.items = {};
|
||||
|
||||
for (const {
|
||||
txID,
|
||||
madeAt,
|
||||
changes,
|
||||
} of this.core.getValidSortedTransactions()) {
|
||||
for (const changeUntyped of changes) {
|
||||
const change = changeUntyped as T;
|
||||
for (const changeUntyped of parseJSON(changes)) {
|
||||
const change = changeUntyped as Item extends CoValue
|
||||
? CoID<Item>
|
||||
: Exclude<Item, CoValue>;
|
||||
let entries = this.items[txID.sessionID];
|
||||
if (!entries) {
|
||||
entries = [];
|
||||
this.items[txID.sessionID] = entries;
|
||||
}
|
||||
entries.push(change);
|
||||
entries.push({ value: change, madeAt, tx: txID });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSingleStream(): T[] | undefined {
|
||||
getSingleStream():
|
||||
| (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)[]
|
||||
| undefined {
|
||||
if (Object.keys(this.items).length === 0) {
|
||||
return undefined;
|
||||
} else if (Object.keys(this.items).length !== 1) {
|
||||
@@ -87,44 +106,204 @@ export class CoStream<
|
||||
);
|
||||
}
|
||||
|
||||
return Object.values(this.items)[0];
|
||||
return Object.values(this.items)[0]?.map((item) => item.value);
|
||||
}
|
||||
|
||||
sessions(): SessionID[] {
|
||||
return Object.keys(this.items) as SessionID[];
|
||||
}
|
||||
|
||||
accounts(): Set<AccountID | AgentID> {
|
||||
return new Set(this.sessions().map(accountOrAgentIDfromSessionID));
|
||||
}
|
||||
|
||||
nthItemIn(
|
||||
sessionID: SessionID,
|
||||
n: number
|
||||
):
|
||||
| {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
}
|
||||
| undefined {
|
||||
const items = this.items[sessionID];
|
||||
if (!items) return;
|
||||
|
||||
const item = items[n];
|
||||
if (!item) return;
|
||||
|
||||
return {
|
||||
by: accountOrAgentIDfromSessionID(sessionID),
|
||||
tx: item.tx,
|
||||
at: new Date(item.madeAt),
|
||||
value: item.value,
|
||||
};
|
||||
}
|
||||
|
||||
lastItemIn(sessionID: SessionID):
|
||||
| {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
}
|
||||
| undefined {
|
||||
const items = this.items[sessionID];
|
||||
if (!items) return;
|
||||
return this.nthItemIn(sessionID, items.length - 1);
|
||||
}
|
||||
|
||||
*itemsIn(sessionID: SessionID) {
|
||||
const items = this.items[sessionID];
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
yield {
|
||||
by: accountOrAgentIDfromSessionID(sessionID),
|
||||
tx: item.tx,
|
||||
at: new Date(item.madeAt),
|
||||
value: item.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
lastItemBy(account: AccountID | AgentID):
|
||||
| {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
}
|
||||
| undefined {
|
||||
let latestItem:
|
||||
| {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item extends CoValue
|
||||
? CoID<Item>
|
||||
: Exclude<Item, CoValue>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
for (const sessionID of Object.keys(this.items)) {
|
||||
if (sessionID.startsWith(account)) {
|
||||
const item = this.lastItemIn(sessionID as SessionID);
|
||||
if (!item) continue;
|
||||
if (!latestItem || item.at > latestItem.at) {
|
||||
latestItem = {
|
||||
by: item.by,
|
||||
tx: item.tx,
|
||||
at: item.at,
|
||||
value: item.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return latestItem;
|
||||
}
|
||||
|
||||
*itemsBy(account: AccountID | AgentID) {
|
||||
// TODO: this can be made more lazy without a huge collect and sort
|
||||
const items = [
|
||||
...Object.keys(this.items).flatMap((sessionID) =>
|
||||
sessionID.startsWith(account)
|
||||
? [...this.itemsIn(sessionID as SessionID)].map((item) => ({
|
||||
in: sessionID as SessionID,
|
||||
...item,
|
||||
}))
|
||||
: []
|
||||
),
|
||||
];
|
||||
|
||||
items.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
toJSON(): {
|
||||
[key: SessionID]: T[];
|
||||
[key: SessionID]: (Item extends CoValue
|
||||
? CoID<Item>
|
||||
: Exclude<Item, CoValue>)[];
|
||||
} {
|
||||
return this.items;
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.items).map(([sessionID, items]) => [
|
||||
sessionID,
|
||||
items.map((item) => item.value),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
subscribe(listener: (coMap: CoStream<T, Meta>) => void): () => void {
|
||||
subscribe(listener: (coStream: this) => void): () => void {
|
||||
return this.core.subscribe((content) => {
|
||||
listener(content as CoStream<T, Meta>);
|
||||
listener(content as this);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
edit(
|
||||
changer: (editable: WriteableCoStream<T, Meta>) => void
|
||||
): CoStream<T, Meta> {
|
||||
const editable = new WriteableCoStream<T, Meta>(this.core);
|
||||
changer(editable);
|
||||
return new CoStream(this.core);
|
||||
export class CoStream<
|
||||
Item extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
>
|
||||
extends CoStreamView<Item, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
push(
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): this {
|
||||
this.core.makeTransaction([isCoValue(item) ? item.id : item], privacy);
|
||||
return new CoStream(this.core) as this;
|
||||
}
|
||||
|
||||
mutate(mutator: (mutable: MutableCoStream<Item, Meta>) => void): this {
|
||||
const mutable = new MutableCoStream<Item, Meta>(this.core);
|
||||
mutator(mutable);
|
||||
return new CoStream(this.core) as this;
|
||||
}
|
||||
|
||||
/** @deprecated Use `mutate` instead. */
|
||||
edit(mutator: (mutable: MutableCoStream<Item, Meta>) => void): this {
|
||||
return this.mutate(mutator);
|
||||
}
|
||||
}
|
||||
|
||||
export class MutableCoStream<
|
||||
Item extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
>
|
||||
extends CoStreamView<Item, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
push(
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
) {
|
||||
this.core.makeTransaction([isCoValue(item) ? item.id : item], privacy);
|
||||
this.fillFromCoValue();
|
||||
}
|
||||
}
|
||||
|
||||
const binary_U_prefixLength = 8; // "binary_U".length;
|
||||
|
||||
export class BinaryCoStream<
|
||||
export class BinaryCoStreamView<
|
||||
Meta extends BinaryCoStreamMeta = { type: "binary" }
|
||||
>
|
||||
extends CoStream<BinaryStreamItem, Meta>
|
||||
implements ReadableCoValue
|
||||
extends CoStreamView<BinaryStreamItem, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
id!: CoID<BinaryCoStream<Meta>>;
|
||||
id!: CoID<this>;
|
||||
|
||||
getBinaryChunks():
|
||||
| (BinaryChunkInfo & { chunks: Uint8Array[]; finished: boolean })
|
||||
getBinaryChunks(
|
||||
allowUnfinished?: boolean
|
||||
):
|
||||
| (BinaryStreamInfo & { chunks: Uint8Array[]; finished: boolean })
|
||||
| undefined {
|
||||
const before = performance.now();
|
||||
// const before = performance.now();
|
||||
const items = this.getSingleStream();
|
||||
|
||||
if (!items) return;
|
||||
@@ -136,10 +315,14 @@ export class BinaryCoStream<
|
||||
return;
|
||||
}
|
||||
|
||||
const end = items[items.length - 1];
|
||||
|
||||
if (end?.type !== "end" && !allowUnfinished) return;
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
let finished = false;
|
||||
let totalLength = 0;
|
||||
// let totalLength = 0;
|
||||
|
||||
for (const item of items.slice(1)) {
|
||||
if (item.type === "end") {
|
||||
@@ -155,15 +338,15 @@ export class BinaryCoStream<
|
||||
const chunk = base64URLtoBytes(
|
||||
item.chunk.slice(binary_U_prefixLength)
|
||||
);
|
||||
totalLength += chunk.length;
|
||||
// totalLength += chunk.length;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const after = performance.now();
|
||||
console.log(
|
||||
"getBinaryChunks bandwidth in MB/s",
|
||||
(1000 * totalLength) / (after - before) / (1024 * 1024)
|
||||
);
|
||||
// const after = performance.now();
|
||||
// console.log(
|
||||
// "getBinaryChunks bandwidth in MB/s",
|
||||
// (1000 * totalLength) / (after - before) / (1024 * 1024)
|
||||
// );
|
||||
|
||||
return {
|
||||
mimeType: start.mimeType,
|
||||
@@ -173,56 +356,89 @@ export class BinaryCoStream<
|
||||
finished,
|
||||
};
|
||||
}
|
||||
|
||||
edit(
|
||||
changer: (editable: WriteableBinaryCoStream<Meta>) => void
|
||||
): BinaryCoStream<Meta> {
|
||||
const editable = new WriteableBinaryCoStream<Meta>(this.core);
|
||||
changer(editable);
|
||||
return new BinaryCoStream(this.core);
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableCoStream<
|
||||
T extends JsonValue,
|
||||
Meta extends JsonObject | null = null
|
||||
>
|
||||
extends CoStream<T, Meta>
|
||||
implements WriteableCoValue
|
||||
{
|
||||
/** @internal */
|
||||
edit(
|
||||
_changer: (editable: WriteableCoStream<T, Meta>) => void
|
||||
): CoStream<T, Meta> {
|
||||
throw new Error("Already editing.");
|
||||
}
|
||||
|
||||
push(item: T, privacy: "private" | "trusting" = "private") {
|
||||
this.core.makeTransaction([item], privacy);
|
||||
this.fillFromCoValue();
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableBinaryCoStream<
|
||||
export class BinaryCoStream<
|
||||
Meta extends BinaryCoStreamMeta = { type: "binary" }
|
||||
>
|
||||
extends BinaryCoStream<Meta>
|
||||
implements WriteableCoValue
|
||||
extends BinaryCoStreamView<Meta>
|
||||
implements CoValue
|
||||
{
|
||||
/** @internal */
|
||||
edit(
|
||||
_changer: (editable: WriteableBinaryCoStream<Meta>) => void
|
||||
): BinaryCoStream<Meta> {
|
||||
throw new Error("Already editing.");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
push(item: BinaryStreamItem, privacy: "private" | "trusting" = "private") {
|
||||
WriteableCoStream.prototype.push.call(this, item, privacy);
|
||||
push(
|
||||
item: BinaryStreamItem,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): this {
|
||||
this.core.makeTransaction([item], privacy);
|
||||
return new BinaryCoStream(this.core) as this;
|
||||
}
|
||||
|
||||
startBinaryStream(
|
||||
settings: BinaryChunkInfo,
|
||||
settings: BinaryStreamInfo,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): this {
|
||||
return this.push(
|
||||
{
|
||||
type: "start",
|
||||
...settings,
|
||||
} satisfies BinaryStreamStart,
|
||||
privacy
|
||||
);
|
||||
}
|
||||
|
||||
pushBinaryStreamChunk(
|
||||
chunk: Uint8Array,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): this {
|
||||
// const before = performance.now();
|
||||
return this.push(
|
||||
{
|
||||
type: "chunk",
|
||||
chunk: `binary_U${bytesToBase64url(chunk)}`,
|
||||
} satisfies BinaryStreamChunk,
|
||||
privacy
|
||||
);
|
||||
// const after = performance.now();
|
||||
// console.log(
|
||||
// "pushBinaryStreamChunk bandwidth in MB/s",
|
||||
// (1000 * chunk.length) / (after - before) / (1024 * 1024)
|
||||
// );
|
||||
}
|
||||
|
||||
endBinaryStream(privacy: "private" | "trusting" = "private"): this {
|
||||
return this.push(
|
||||
{
|
||||
type: "end",
|
||||
} satisfies BinaryStreamEnd,
|
||||
privacy
|
||||
);
|
||||
}
|
||||
|
||||
mutate(mutator: (mutable: MutableBinaryCoStream<Meta>) => void): this {
|
||||
const mutable = new MutableBinaryCoStream<Meta>(this.core);
|
||||
mutator(mutable);
|
||||
return new BinaryCoStream(this.core) as this;
|
||||
}
|
||||
|
||||
/** @deprecated Use `mutate` instead. */
|
||||
edit(mutator: (mutable: MutableBinaryCoStream<Meta>) => void): this {
|
||||
return this.mutate(mutator);
|
||||
}
|
||||
}
|
||||
|
||||
export class MutableBinaryCoStream<
|
||||
Meta extends BinaryCoStreamMeta = { type: "binary" }
|
||||
>
|
||||
extends BinaryCoStreamView<Meta>
|
||||
implements CoValue
|
||||
{
|
||||
/** @internal */
|
||||
push(item: BinaryStreamItem, privacy: "private" | "trusting" = "private") {
|
||||
MutableCoStream.prototype.push.call(this, item, privacy);
|
||||
}
|
||||
|
||||
startBinaryStream(
|
||||
settings: BinaryStreamInfo,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
) {
|
||||
this.push(
|
||||
@@ -238,7 +454,7 @@ export class WriteableBinaryCoStream<
|
||||
chunk: Uint8Array,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
) {
|
||||
const before = performance.now();
|
||||
// const before = performance.now();
|
||||
this.push(
|
||||
{
|
||||
type: "chunk",
|
||||
@@ -246,11 +462,11 @@ export class WriteableBinaryCoStream<
|
||||
} satisfies BinaryStreamChunk,
|
||||
privacy
|
||||
);
|
||||
const after = performance.now();
|
||||
console.log(
|
||||
"pushBinaryStreamChunk bandwidth in MB/s",
|
||||
(1000 * chunk.length) / (after - before) / (1024 * 1024)
|
||||
);
|
||||
// const after = performance.now();
|
||||
// console.log(
|
||||
// "pushBinaryStreamChunk bandwidth in MB/s",
|
||||
// (1000 * chunk.length) / (after - before) / (1024 * 1024)
|
||||
// );
|
||||
}
|
||||
|
||||
endBinaryStream(privacy: "private" | "trusting" = "private") {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { JsonObject } from '../jsonValue.js';
|
||||
import { CoID, ReadableCoValue } from '../coValue.js';
|
||||
import { CoValueCore } from '../coValueCore.js';
|
||||
import { Group } from '../index.js';
|
||||
|
||||
export class Static<T extends JsonObject> implements ReadableCoValue{
|
||||
id: CoID<Static<T>>;
|
||||
type = "static" as const;
|
||||
core: CoValueCore;
|
||||
|
||||
constructor(core: CoValueCore) {
|
||||
this.id = core.id as CoID<Static<T>>;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
get meta(): T {
|
||||
return this.core.header.meta as T;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.core.getGroup();
|
||||
}
|
||||
|
||||
toJSON(): JsonObject {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
subscribe(_listener: (coMap: Static<T>) => void): () => void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
@@ -6,33 +6,39 @@ import { randomBytes } from "@noble/ciphers/webcrypto/utils";
|
||||
import { AgentID, RawCoID, TransactionID } from "./ids.js";
|
||||
import { base64URLtoBytes, bytesToBase64url } from "./base64url.js";
|
||||
|
||||
import { createBLAKE3 } from 'hash-wasm';
|
||||
import { stableStringify } from "./fastJsonStableStringify.js";
|
||||
import { createBLAKE3 } from "hash-wasm";
|
||||
import { Stringified, parseJSON, stableStringify } from "./jsonStringify.js";
|
||||
|
||||
let blake3Instance: Awaited<ReturnType<typeof createBLAKE3>>;
|
||||
let blake3HashOnce: (data: Uint8Array) => Uint8Array;
|
||||
let blake3HashOnceWithContext: (data: Uint8Array, {context}: {context: Uint8Array}) => Uint8Array;
|
||||
let blake3incrementalUpdateSLOW_WITH_DEVTOOLS: (state: Uint8Array, data: Uint8Array) => Uint8Array;
|
||||
let blake3HashOnceWithContext: (
|
||||
data: Uint8Array,
|
||||
{ context }: { context: Uint8Array }
|
||||
) => Uint8Array;
|
||||
let blake3incrementalUpdateSLOW_WITH_DEVTOOLS: (
|
||||
state: Uint8Array,
|
||||
data: Uint8Array
|
||||
) => Uint8Array;
|
||||
let blake3digestForState: (state: Uint8Array) => Uint8Array;
|
||||
|
||||
export const cryptoReady = new Promise<void>((resolve) => {
|
||||
createBLAKE3().then(bl3 => {
|
||||
createBLAKE3().then((bl3) => {
|
||||
blake3Instance = bl3;
|
||||
blake3HashOnce = (data) => {
|
||||
return bl3.init().update(data).digest('binary');
|
||||
}
|
||||
blake3HashOnceWithContext = (data, {context}) => {
|
||||
return bl3.init().update(context).update(data).digest('binary');
|
||||
}
|
||||
return bl3.init().update(data).digest("binary");
|
||||
};
|
||||
blake3HashOnceWithContext = (data, { context }) => {
|
||||
return bl3.init().update(context).update(data).digest("binary");
|
||||
};
|
||||
blake3incrementalUpdateSLOW_WITH_DEVTOOLS = (state, data) => {
|
||||
bl3.load(state).update(data);
|
||||
return bl3.save();
|
||||
}
|
||||
};
|
||||
blake3digestForState = (state) => {
|
||||
return bl3.load(state).digest('binary');
|
||||
}
|
||||
return bl3.load(state).digest("binary");
|
||||
};
|
||||
resolve();
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
export type SignerSecret = `signerSecret_z${string}`;
|
||||
@@ -149,12 +155,17 @@ export function getAgentSealerSecret(agentSecret: AgentSecret): SealerSecret {
|
||||
return agentSecret.split("/")[0] as SealerSecret;
|
||||
}
|
||||
|
||||
export function seal<T extends JsonValue>(
|
||||
message: T,
|
||||
from: SealerSecret,
|
||||
to: SealerID,
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID }
|
||||
): Sealed<T> {
|
||||
export function seal<T extends JsonValue>({
|
||||
message,
|
||||
from,
|
||||
to,
|
||||
nOnceMaterial,
|
||||
}: {
|
||||
message: T;
|
||||
from: SealerSecret;
|
||||
to: SealerID;
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID };
|
||||
}): Sealed<T> {
|
||||
const nOnce = blake3HashOnce(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial))
|
||||
).slice(0, 24);
|
||||
@@ -220,9 +231,12 @@ export class StreamingHash {
|
||||
}
|
||||
|
||||
update(value: JsonValue) {
|
||||
const encoded = textEncoder.encode(stableStringify(value))
|
||||
const encoded = textEncoder.encode(stableStringify(value));
|
||||
// const before = performance.now();
|
||||
this.state = blake3incrementalUpdateSLOW_WITH_DEVTOOLS(this.state, encoded);
|
||||
this.state = blake3incrementalUpdateSLOW_WITH_DEVTOOLS(
|
||||
this.state,
|
||||
encoded
|
||||
);
|
||||
// const after = performance.now();
|
||||
// console.log(`Hashing throughput in MB/s`, 1000 * (encoded.length / (after - before)) / (1024 * 1024));
|
||||
}
|
||||
@@ -316,11 +330,11 @@ export function encryptKeySecret(keys: {
|
||||
};
|
||||
}
|
||||
|
||||
function decrypt<T extends JsonValue, N extends JsonValue>(
|
||||
function decryptRaw<T extends JsonValue, N extends JsonValue>(
|
||||
encrypted: Encrypted<T, N>,
|
||||
keySecret: KeySecret,
|
||||
nOnceMaterial: N
|
||||
): T | undefined {
|
||||
): Stringified<T> {
|
||||
const keySecretBytes = base58.decode(
|
||||
keySecret.substring("keySecret_z".length)
|
||||
);
|
||||
@@ -333,13 +347,30 @@ function decrypt<T extends JsonValue, N extends JsonValue>(
|
||||
);
|
||||
const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext);
|
||||
|
||||
return textDecoder.decode(plaintext) as Stringified<T>;
|
||||
}
|
||||
|
||||
function decrypt<T extends JsonValue, N extends JsonValue>(
|
||||
encrypted: Encrypted<T, N>,
|
||||
keySecret: KeySecret,
|
||||
nOnceMaterial: N
|
||||
): T | undefined {
|
||||
try {
|
||||
return JSON.parse(textDecoder.decode(plaintext));
|
||||
return parseJSON(decryptRaw(encrypted, keySecret, nOnceMaterial));
|
||||
} catch (e) {
|
||||
console.error("Decryption error", e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptRawForTransaction<T extends JsonValue>(
|
||||
encrypted: Encrypted<T, { in: RawCoID; tx: TransactionID }>,
|
||||
keySecret: KeySecret,
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID }
|
||||
): Stringified<T> | undefined {
|
||||
return decryptRaw(encrypted, keySecret, nOnceMaterial);
|
||||
}
|
||||
|
||||
export function decryptForTransaction<T extends JsonValue>(
|
||||
encrypted: Encrypted<T, { in: RawCoID; tx: TransactionID }>,
|
||||
keySecret: KeySecret,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CoID, CoValueImpl } from "./coValue.js";
|
||||
import { CoID, CoValue, AnyCoValue, AnyCoMap, AnyCoList } from "./coValue.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
@@ -15,18 +15,21 @@ import {
|
||||
agentSecretFromSecretSeed,
|
||||
getAgentID,
|
||||
} from "./crypto.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import { AgentID, SessionID, isAgentID } from "./ids.js";
|
||||
import { AccountID, GeneralizedControlledAccount, Profile } from "./account.js";
|
||||
import { Role } from "./permissions.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import { BinaryCoStream, BinaryCoStreamMeta, CoStream } from "./coValues/coStream.js";
|
||||
import {
|
||||
BinaryCoStream,
|
||||
BinaryCoStreamMeta,
|
||||
CoStream,
|
||||
} from "./coValues/coStream.js";
|
||||
|
||||
export type GroupContent = {
|
||||
profile: CoID<Profile> | null;
|
||||
profile?: CoID<Profile> | null;
|
||||
[key: AccountID | AgentID]: Role;
|
||||
readKey: KeyID;
|
||||
readKey?: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountID | AgentID}`]: Sealed<KeySecret>;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
@@ -35,7 +38,7 @@ export type GroupContent = {
|
||||
};
|
||||
|
||||
export function expectGroupContent(
|
||||
content: CoValueImpl
|
||||
content: CoValue
|
||||
): CoMap<GroupContent, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
@@ -66,6 +69,7 @@ export function expectGroupContent(
|
||||
* ```
|
||||
* */
|
||||
export class Group {
|
||||
/** @category 4. Underlying CoMap */
|
||||
underlyingMap: CoMap<GroupContent, JsonObject | null>;
|
||||
/** @internal */
|
||||
node: LocalNode;
|
||||
@@ -79,12 +83,20 @@ export class Group {
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
/** Returns the `CoID` of the `Group`. */
|
||||
/**
|
||||
* Returns the `CoID` of the `Group`.
|
||||
*
|
||||
* @category 4. Underlying CoMap
|
||||
*/
|
||||
get id(): CoID<CoMap<GroupContent, JsonObject | null>> {
|
||||
return this.underlyingMap.id;
|
||||
}
|
||||
|
||||
/** Returns the current role of a given account. */
|
||||
/**
|
||||
* Returns the current role of a given account.
|
||||
*
|
||||
* @category 1. Role reading
|
||||
*/
|
||||
roleOf(accountID: AccountID): Role | undefined {
|
||||
return this.roleOfInternal(accountID);
|
||||
}
|
||||
@@ -94,20 +106,28 @@ export class Group {
|
||||
return this.underlyingMap.get(accountID);
|
||||
}
|
||||
|
||||
/** Returns the role of the current account in the group. */
|
||||
/**
|
||||
* Returns the role of the current account in the group.
|
||||
*
|
||||
* @category 1. Role reading
|
||||
*/
|
||||
myRole(): Role | undefined {
|
||||
return this.roleOfInternal(this.node.account.id);
|
||||
}
|
||||
|
||||
/** Directly grants a new member a role in the group. The current account must be an
|
||||
* admin to be able to do so. Throws otherwise. */
|
||||
/**
|
||||
* Directly grants a new member a role in the group. The current account must be an
|
||||
* admin to be able to do so. Throws otherwise.
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
addMember(accountID: AccountID, role: Role) {
|
||||
this.addMemberInternal(accountID, role);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
addMemberInternal(accountID: AccountID | AgentID, role: Role) {
|
||||
this.underlyingMap = this.underlyingMap.edit((map) => {
|
||||
this.underlyingMap = this.underlyingMap.mutate((map) => {
|
||||
const currentReadKey = this.underlyingMap.core.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
@@ -127,15 +147,15 @@ export class Group {
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal(
|
||||
currentReadKey.secret,
|
||||
this.underlyingMap.core.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(agent),
|
||||
{
|
||||
seal({
|
||||
message: currentReadKey.secret,
|
||||
from: this.underlyingMap.core.node.account.currentSealerSecret(),
|
||||
to: getAgentSealerID(agent),
|
||||
nOnceMaterial: {
|
||||
in: this.underlyingMap.core.id,
|
||||
tx: this.underlyingMap.core.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
},
|
||||
}),
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
@@ -173,7 +193,7 @@ export class Group {
|
||||
|
||||
const newReadKey = newRandomKeySecret();
|
||||
|
||||
this.underlyingMap = this.underlyingMap.edit((map) => {
|
||||
this.underlyingMap = this.underlyingMap.mutate((map) => {
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.node.resolveAccountAgent(
|
||||
readerID,
|
||||
@@ -182,15 +202,15 @@ export class Group {
|
||||
|
||||
map.set(
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
seal(
|
||||
newReadKey.secret,
|
||||
this.underlyingMap.core.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(reader),
|
||||
{
|
||||
seal({
|
||||
message: newReadKey.secret,
|
||||
from: this.underlyingMap.core.node.account.currentSealerSecret(),
|
||||
to: getAgentSealerID(reader),
|
||||
nOnceMaterial: {
|
||||
in: this.underlyingMap.core.id,
|
||||
tx: this.underlyingMap.core.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
},
|
||||
}),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
@@ -208,23 +228,33 @@ export class Group {
|
||||
});
|
||||
}
|
||||
|
||||
/** Strips the specified member of all roles (preventing future writes in
|
||||
/**
|
||||
* Strips the specified member of all roles (preventing future writes in
|
||||
* the group and owned values) and rotates the read encryption key for that group
|
||||
* (preventing reads of new content in the group and owned values) */
|
||||
* (preventing reads of new content in the group and owned values)
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
removeMember(accountID: AccountID) {
|
||||
this.removeMemberInternal(accountID);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
removeMemberInternal(accountID: AccountID | AgentID) {
|
||||
this.underlyingMap = this.underlyingMap.edit((map) => {
|
||||
this.underlyingMap = this.underlyingMap.mutate((map) => {
|
||||
map.set(accountID, "revoked", "trusting");
|
||||
});
|
||||
|
||||
this.rotateReadKey();
|
||||
}
|
||||
|
||||
/** Creates an invite for new members to indirectly join the group, allowing them to grant themselves the specified role with the InviteSecret (a string starting with "inviteSecret_") - use `LocalNode.acceptInvite()` for this purpose. */
|
||||
/**
|
||||
* Creates an invite for new members to indirectly join the group,
|
||||
* allowing them to grant themselves the specified role with the InviteSecret
|
||||
* (a string starting with "inviteSecret_") - use `LocalNode.acceptInvite()` for this purpose.
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
|
||||
const secretSeed = newRandomSecretSeed();
|
||||
|
||||
@@ -236,12 +266,22 @@ export class Group {
|
||||
return inviteSecretFromSecretSeed(secretSeed);
|
||||
}
|
||||
|
||||
/** Creates a new `CoMap` within this group, with the specified specialized
|
||||
* `CoMap` type `M` and optional static metadata. */
|
||||
createMap<M extends CoMap<{ [key: string]: JsonValue }, JsonObject | null>>(
|
||||
meta?: M["meta"]
|
||||
/**
|
||||
* Creates a new `CoMap` within this group, with the specified specialized
|
||||
* `CoMap` type `M` and optional static metadata.
|
||||
*
|
||||
* @category 3. Value creation
|
||||
*/
|
||||
createMap<M extends AnyCoMap>(
|
||||
init?: {
|
||||
[K in keyof M["_shape"]]: M["_shape"][K] extends AnyCoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K];
|
||||
},
|
||||
meta?: M["meta"],
|
||||
initPrivacy: "trusting" | "private" = "trusting"
|
||||
): M {
|
||||
return this.node
|
||||
let map = this.node
|
||||
.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
@@ -252,14 +292,30 @@ export class Group {
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as M;
|
||||
|
||||
if (init) {
|
||||
for (const [key, value] of Object.entries(init)) {
|
||||
map = map.set(key, value, initPrivacy);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Creates a new `CoList` within this group, with the specified specialized
|
||||
* `CoList` type `L` and optional static metadata. */
|
||||
createList<L extends CoList<JsonValue, JsonObject | null>>(
|
||||
meta?: L["meta"]
|
||||
/**
|
||||
* Creates a new `CoList` within this group, with the specified specialized
|
||||
* `CoList` type `L` and optional static metadata.
|
||||
*
|
||||
* @category 3. Value creation
|
||||
*/
|
||||
createList<L extends AnyCoList>(
|
||||
init?: (L["_item"] extends CoValue
|
||||
? CoID<L["_item"]> | L["_item"]
|
||||
: L["_item"])[],
|
||||
meta?: L["meta"],
|
||||
initPrivacy: "trusting" | "private" = "trusting"
|
||||
): L {
|
||||
return this.node
|
||||
let list = this.node
|
||||
.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: {
|
||||
@@ -270,9 +326,18 @@ export class Group {
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as L;
|
||||
|
||||
if (init) {
|
||||
for (const item of init) {
|
||||
list = list.append(item, undefined, initPrivacy);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
createStream<C extends CoStream<JsonValue, JsonObject | null>>(
|
||||
/** @category 3. Value creation */
|
||||
createStream<C extends CoStream<JsonValue | CoValue, JsonObject | null>>(
|
||||
meta?: C["meta"]
|
||||
): C {
|
||||
return this.node
|
||||
@@ -288,9 +353,10 @@ export class Group {
|
||||
.getCurrentContent() as C;
|
||||
}
|
||||
|
||||
createBinaryStream<
|
||||
C extends BinaryCoStream<BinaryCoStreamMeta>
|
||||
>(meta: C["meta"] = { type: "binary" }): C {
|
||||
/** @category 3. Value creation */
|
||||
createBinaryStream<C extends BinaryCoStream<BinaryCoStreamMeta>>(
|
||||
meta: C["meta"] = { type: "binary" }
|
||||
): C {
|
||||
return this.node
|
||||
.createCoValue({
|
||||
type: "costream",
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { CoValueCore, newRandomSessionID } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import type { CoValue, ReadableCoValue } from "./coValue.js";
|
||||
import { CoMap, WriteableCoMap } from "./coValues/coMap.js";
|
||||
import { CoList, WriteableCoList } from "./coValues/coList.js";
|
||||
import {
|
||||
CoValueCore,
|
||||
newRandomSessionID,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
} from "./coValueCore.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import type { CoValue } from "./coValue.js";
|
||||
import { CoMap, MutableCoMap } from "./coValues/coMap.js";
|
||||
import { CoList, MutableCoList } from "./coValues/coList.js";
|
||||
import {
|
||||
CoStream,
|
||||
WriteableCoStream,
|
||||
MutableCoStream,
|
||||
BinaryCoStream,
|
||||
WriteableBinaryCoStream,
|
||||
MutableBinaryCoStream,
|
||||
} from "./coValues/coStream.js";
|
||||
import {
|
||||
agentSecretFromBytes,
|
||||
@@ -18,24 +22,33 @@ import {
|
||||
agentSecretFromSecretSeed,
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
cryptoReady
|
||||
cryptoReady,
|
||||
} from "./crypto.js";
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
|
||||
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { base64URLtoBytes, bytesToBase64url } from "./base64url.js";
|
||||
import { parseJSON } from "./jsonStringify.js";
|
||||
|
||||
import type { SessionID, AgentID } from "./ids.js";
|
||||
import type { CoID, CoValueImpl } from "./coValue.js";
|
||||
import type { BinaryChunkInfo, BinaryCoStreamMeta } from "./coValues/coStream.js";
|
||||
import type { CoID, AnyCoValue } from "./coValue.js";
|
||||
import type { Queried } from "./queries.js";
|
||||
import type { QueriedCoStream } from "./queriedCoValues/queriedCoStream.js";
|
||||
import type { QueriedCoList } from "./queriedCoValues/queriedCoList.js";
|
||||
import type { QueriedCoMap } from "./queriedCoValues/queriedCoMap.js";
|
||||
import type {
|
||||
BinaryStreamInfo,
|
||||
BinaryCoStreamMeta,
|
||||
} from "./coValues/coStream.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type { SyncMessage, Peer } from "./sync.js";
|
||||
import type { AgentSecret } from "./crypto.js";
|
||||
import type { AccountID, Profile } from "./account.js";
|
||||
import type { AccountID, Account, Profile } from "./account.js";
|
||||
import type { InviteSecret } from "./group.js";
|
||||
import type * as Media from "./media.js";
|
||||
|
||||
type Value = JsonValue | CoValueImpl;
|
||||
type Value = JsonValue | AnyCoValue;
|
||||
|
||||
/** @hidden */
|
||||
export const cojsonInternals = {
|
||||
@@ -53,38 +66,42 @@ export const cojsonInternals = {
|
||||
shortHashLength,
|
||||
expectGroupContent,
|
||||
base64URLtoBytes,
|
||||
bytesToBase64url
|
||||
bytesToBase64url,
|
||||
parseJSON,
|
||||
};
|
||||
|
||||
export {
|
||||
LocalNode,
|
||||
Group,
|
||||
CoMap,
|
||||
WriteableCoMap,
|
||||
MutableCoMap,
|
||||
CoList,
|
||||
WriteableCoList,
|
||||
MutableCoList,
|
||||
CoStream,
|
||||
WriteableCoStream,
|
||||
MutableCoStream,
|
||||
BinaryCoStream,
|
||||
WriteableBinaryCoStream,
|
||||
MutableBinaryCoStream,
|
||||
CoValue,
|
||||
CoID,
|
||||
AnyCoValue,
|
||||
Queried,
|
||||
QueriedCoMap,
|
||||
QueriedCoList,
|
||||
QueriedCoStream,
|
||||
AccountID,
|
||||
Account,
|
||||
Profile,
|
||||
SessionID,
|
||||
Media,
|
||||
CoValueCore,
|
||||
AnonymousControlledAccount,
|
||||
ControlledAccount,
|
||||
cryptoReady as cojsonReady,
|
||||
};
|
||||
|
||||
export type {
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
Value,
|
||||
JsonValue,
|
||||
CoValue,
|
||||
ReadableCoValue,
|
||||
CoValueImpl,
|
||||
CoID,
|
||||
AccountID,
|
||||
Profile,
|
||||
SessionID,
|
||||
Peer,
|
||||
BinaryChunkInfo,
|
||||
BinaryStreamInfo,
|
||||
BinaryCoStreamMeta,
|
||||
AgentID,
|
||||
AgentSecret,
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
// adapted from fast-json-stable-stringify (https://github.com/epoberezkin/fast-json-stable-stringify)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function stableStringify(data: any): string | undefined {
|
||||
export type Stringified<T> = string & { __type: T };
|
||||
|
||||
export function stableStringify<T>(data: T): Stringified<T>
|
||||
export function stableStringify(data: undefined): undefined
|
||||
export function stableStringify<T>(data: T | undefined): Stringified<T> | undefined {
|
||||
const cycles = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const seen: any[] = [];
|
||||
let node = data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let node = data as any;
|
||||
|
||||
if (node && node.toJSON && typeof node.toJSON === "function") {
|
||||
node = node.toJSON();
|
||||
}
|
||||
|
||||
if (node === undefined) return;
|
||||
if (typeof node == "number") return isFinite(node) ? "" + node : "null";
|
||||
if (typeof node == "number")
|
||||
return (isFinite(node) ? "" + node : "null") as Stringified<T>;
|
||||
if (typeof node !== "object") {
|
||||
if (typeof node === "string" && (node.startsWith("encrypted_U") || node.startsWith("binary_U"))) {
|
||||
return `"${node}"`;
|
||||
if (
|
||||
typeof node === "string" &&
|
||||
(node.startsWith("encrypted_U") || node.startsWith("binary_U"))
|
||||
) {
|
||||
return `"${node}"` as Stringified<T>;
|
||||
}
|
||||
return JSON.stringify(node);
|
||||
return JSON.stringify(node) as Stringified<T>;
|
||||
}
|
||||
|
||||
let i, out;
|
||||
@@ -28,13 +36,13 @@ export function stableStringify(data: any): string | undefined {
|
||||
if (i) out += ",";
|
||||
out += stableStringify(node[i]) || "null";
|
||||
}
|
||||
return out + "]";
|
||||
return (out + "]") as Stringified<T>;
|
||||
}
|
||||
|
||||
if (node === null) return "null";
|
||||
if (node === null) return "null" as Stringified<T>;
|
||||
|
||||
if (seen.indexOf(node) !== -1) {
|
||||
if (cycles) return JSON.stringify("__cycle__");
|
||||
if (cycles) return JSON.stringify("__cycle__") as Stringified<T>;
|
||||
throw new TypeError("Converting circular structure to JSON");
|
||||
}
|
||||
|
||||
@@ -50,5 +58,9 @@ export function stableStringify(data: any): string | undefined {
|
||||
out += JSON.stringify(key) + ":" + value;
|
||||
}
|
||||
seen.splice(seenIndex, 1);
|
||||
return "{" + out + "}";
|
||||
return ("{" + out + "}") as Stringified<T>;
|
||||
}
|
||||
|
||||
export function parseJSON<T>(json: Stringified<T>): T {
|
||||
return JSON.parse(json);
|
||||
}
|
||||
@@ -3,4 +3,4 @@ import { RawCoID } from './ids.js';
|
||||
export type JsonAtom = string | number | boolean | null;
|
||||
export type JsonValue = JsonAtom | JsonArray | JsonObject | RawCoID;
|
||||
export type JsonArray = JsonValue[];
|
||||
export type JsonObject = { [key: string]: JsonValue; };
|
||||
export type JsonObject = { [key: string]: JsonValue | undefined; };
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
} from "./crypto.js";
|
||||
import { CoValueCore, CoValueHeader, newRandomSessionID } from "./coValueCore.js";
|
||||
import {
|
||||
CoValueCore,
|
||||
CoValueHeader,
|
||||
newRandomSessionID,
|
||||
} from "./coValueCore.js";
|
||||
import {
|
||||
InviteSecret,
|
||||
Group,
|
||||
@@ -19,9 +23,10 @@ import {
|
||||
} from "./group.js";
|
||||
import { Peer, SyncManager } from "./sync.js";
|
||||
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
|
||||
import { CoID, CoValueImpl } from "./coValue.js";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { Queried, query } from "./queries.js";
|
||||
import {
|
||||
Account,
|
||||
AccountGroup,
|
||||
AccountMeta,
|
||||
accountHeaderForInitialAgentSecret,
|
||||
GeneralizedControlledAccount,
|
||||
@@ -30,9 +35,9 @@ import {
|
||||
AccountID,
|
||||
Profile,
|
||||
AccountContent,
|
||||
AccountMap,
|
||||
} from "./account.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { CoValue } from "./index.js";
|
||||
|
||||
/** A `LocalNode` represents a local view of a set of loaded `CoValue`s, from the perspective of a particular account (or primitive cryptographic agent).
|
||||
|
||||
@@ -48,11 +53,14 @@ const { localNode } = useJazz();
|
||||
export class LocalNode {
|
||||
/** @internal */
|
||||
coValues: { [key: RawCoID]: CoValueState } = {};
|
||||
/** @internal */
|
||||
/** @category 3. Low-level */
|
||||
account: GeneralizedControlledAccount;
|
||||
/** @category 3. Low-level */
|
||||
currentSessionID: SessionID;
|
||||
sync = new SyncManager(this);
|
||||
/** @category 3. Low-level */
|
||||
syncManager = new SyncManager(this);
|
||||
|
||||
/** @category 3. Low-level */
|
||||
constructor(
|
||||
account: GeneralizedControlledAccount,
|
||||
currentSessionID: SessionID
|
||||
@@ -61,6 +69,7 @@ export class LocalNode {
|
||||
this.currentSessionID = currentSessionID;
|
||||
}
|
||||
|
||||
/** @category 2. Node Creation */
|
||||
static withNewlyCreatedAccount(
|
||||
name: string,
|
||||
initialAgentSecret = newRandomAgentSecret()
|
||||
@@ -91,6 +100,7 @@ export class LocalNode {
|
||||
};
|
||||
}
|
||||
|
||||
/** @category 2. Node Creation */
|
||||
static async withLoadedAccount(
|
||||
accountID: AccountID,
|
||||
accountSecret: AgentSecret,
|
||||
@@ -105,7 +115,7 @@ export class LocalNode {
|
||||
const accountPromise = loadingNode.load(accountID);
|
||||
|
||||
for (const peer of peersToLoadFrom) {
|
||||
loadingNode.sync.addPeer(peer);
|
||||
loadingNode.syncManager.addPeer(peer);
|
||||
}
|
||||
|
||||
const account = await accountPromise;
|
||||
@@ -115,8 +125,8 @@ export class LocalNode {
|
||||
new ControlledAccount(accountSecret, account, loadingNode),
|
||||
sessionID
|
||||
);
|
||||
node.sync = loadingNode.sync;
|
||||
node.sync.local = node;
|
||||
node.syncManager = loadingNode.syncManager;
|
||||
node.syncManager.local = node;
|
||||
|
||||
return node;
|
||||
}
|
||||
@@ -126,7 +136,7 @@ export class LocalNode {
|
||||
const coValue = new CoValueCore(header, this);
|
||||
this.coValues[coValue.id] = { state: "loaded", coValue: coValue };
|
||||
|
||||
void this.sync.syncCoValue(coValue);
|
||||
void this.syncManager.syncCoValue(coValue);
|
||||
|
||||
return coValue;
|
||||
}
|
||||
@@ -139,7 +149,7 @@ export class LocalNode {
|
||||
|
||||
this.coValues[id] = entry;
|
||||
|
||||
this.sync.loadFromPeers(id);
|
||||
this.syncManager.loadFromPeers(id);
|
||||
}
|
||||
if (entry.state === "loaded") {
|
||||
return Promise.resolve(entry.coValue);
|
||||
@@ -151,28 +161,51 @@ export class LocalNode {
|
||||
* Loads a CoValue's content, syncing from peers as necessary and resolving the returned
|
||||
* promise once a first version has been loaded. See `coValue.subscribe()` and `node.useTelepathicData()`
|
||||
* for listening to subsequent updates to the CoValue.
|
||||
*
|
||||
* @category 3. Low-level
|
||||
*/
|
||||
async load<T extends CoValueImpl>(id: CoID<T>): Promise<T> {
|
||||
async load<T extends CoValue>(id: CoID<T>): Promise<T> {
|
||||
return (await this.loadCoValue(id)).getCurrentContent() as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a profile associated with an account. `Profile` is at least a `CoMap<{string: name}>`,
|
||||
* but might contain other, app-specific properties.
|
||||
*/
|
||||
async loadProfile(id: AccountID): Promise<Profile> {
|
||||
const account = await this.load<AccountMap>(id);
|
||||
const profileID = account.get("profile");
|
||||
/** @category 3. Low-level */
|
||||
subscribe<T extends CoValue>(
|
||||
id: CoID<T>,
|
||||
callback: (update: T) => void
|
||||
): () => void {
|
||||
let stopped = false;
|
||||
let unsubscribe!: () => void;
|
||||
|
||||
if (!profileID) {
|
||||
throw new Error(`Account ${id} has no profile`);
|
||||
}
|
||||
return (
|
||||
await this.loadCoValue(profileID)
|
||||
).getCurrentContent() as Profile;
|
||||
console.log("Subscribing to " + id);
|
||||
|
||||
this.load(id)
|
||||
.then((coValue) => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
unsubscribe = coValue.subscribe(callback);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error subscribing to ", id, e);
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log("Unsubscribing from " + id);
|
||||
stopped = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}
|
||||
|
||||
async acceptInvite<T extends CoValueImpl>(
|
||||
/** @category 1. High-level */
|
||||
query<T extends CoValue>(
|
||||
id: CoID<T>,
|
||||
callback: (update: Queried<T> | undefined) => void
|
||||
): () => void {
|
||||
return query(id, this, callback);
|
||||
}
|
||||
|
||||
/** @category 1. High-level */
|
||||
async acceptInvite<T extends CoValue>(
|
||||
groupOrOwnedValueID: CoID<T>,
|
||||
inviteSecret: InviteSecret
|
||||
): Promise<void> {
|
||||
@@ -204,11 +237,8 @@ export class LocalNode {
|
||||
}
|
||||
});
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error("Couldn't find invite before timeout")
|
||||
),
|
||||
1000
|
||||
() => reject(new Error("Couldn't find invite before timeout")),
|
||||
2000
|
||||
);
|
||||
});
|
||||
|
||||
@@ -224,7 +254,9 @@ export class LocalNode {
|
||||
(existingRole === "writer" && inviteRole === "reader") ||
|
||||
(existingRole === "reader" && inviteRole === "readerInvite")
|
||||
) {
|
||||
console.debug("Not accepting invite that would replace or downgrade role");
|
||||
console.debug(
|
||||
"Not accepting invite that would replace or downgrade role"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -242,7 +274,8 @@ export class LocalNode {
|
||||
: "reader"
|
||||
);
|
||||
|
||||
group.underlyingMap.core._sessions = groupAsInvite.underlyingMap.core.sessions;
|
||||
group.underlyingMap.core._sessions =
|
||||
groupAsInvite.underlyingMap.core.sessions;
|
||||
group.underlyingMap.core._cachedContent = undefined;
|
||||
|
||||
for (const groupListener of group.underlyingMap.core.listeners) {
|
||||
@@ -292,11 +325,12 @@ export class LocalNode {
|
||||
name: string,
|
||||
agentSecret = newRandomAgentSecret()
|
||||
): ControlledAccount {
|
||||
const accountAgentID = getAgentID(agentSecret);
|
||||
const account = this.createCoValue(
|
||||
accountHeaderForInitialAgentSecret(agentSecret)
|
||||
).testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
newRandomSessionID(getAgentID(agentSecret))
|
||||
newRandomSessionID(accountAgentID)
|
||||
);
|
||||
|
||||
const accountAsGroup = new Group(
|
||||
@@ -304,22 +338,22 @@ export class LocalNode {
|
||||
account.node
|
||||
);
|
||||
|
||||
accountAsGroup.underlyingMap.edit((editable) => {
|
||||
editable.set(getAgentID(agentSecret), "admin", "trusting");
|
||||
accountAsGroup.underlyingMap.mutate((editable) => {
|
||||
editable.set(accountAgentID, "admin", "trusting");
|
||||
|
||||
const readKey = newRandomKeySecret();
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${getAgentID(agentSecret)}`,
|
||||
seal(
|
||||
readKey.secret,
|
||||
getAgentSealerSecret(agentSecret),
|
||||
getAgentSealerID(getAgentID(agentSecret)),
|
||||
{
|
||||
`${readKey.id}_for_${accountAgentID}`,
|
||||
seal({
|
||||
message: readKey.secret,
|
||||
from: getAgentSealerSecret(agentSecret),
|
||||
to: getAgentSealerID(accountAgentID),
|
||||
nOnceMaterial: {
|
||||
in: account.id,
|
||||
tx: account.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
},
|
||||
}),
|
||||
"trusting"
|
||||
);
|
||||
|
||||
@@ -332,28 +366,38 @@ export class LocalNode {
|
||||
account.node
|
||||
);
|
||||
|
||||
const profile = accountAsGroup.createMap<Profile>({
|
||||
type: "profile",
|
||||
});
|
||||
const profile = accountAsGroup.createMap<Profile>(
|
||||
{ name },
|
||||
{
|
||||
type: "profile",
|
||||
},
|
||||
"trusting"
|
||||
);
|
||||
|
||||
profile.edit((editable) => {
|
||||
editable.set("name", name, "trusting");
|
||||
});
|
||||
|
||||
accountAsGroup.underlyingMap.edit((editable) => {
|
||||
editable.set("profile", profile.id, "trusting");
|
||||
});
|
||||
accountAsGroup.underlyingMap.set("profile", profile.id, "trusting");
|
||||
|
||||
const accountOnThisNode = this.expectCoValueLoaded(account.id);
|
||||
|
||||
accountOnThisNode._sessions = {...accountAsGroup.underlyingMap.core.sessions};
|
||||
accountOnThisNode._sessions = {
|
||||
...accountAsGroup.underlyingMap.core.sessions,
|
||||
};
|
||||
accountOnThisNode._cachedContent = undefined;
|
||||
|
||||
const profileOnThisNode = this.createCoValue(profile.core.header);
|
||||
|
||||
profileOnThisNode._sessions = {
|
||||
...profile.core.sessions,
|
||||
};
|
||||
profileOnThisNode._cachedContent = undefined;
|
||||
|
||||
return controlledAccount;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
resolveAccountAgent(id: AccountID | AgentID, expectation?: string): AgentID {
|
||||
resolveAccountAgent(
|
||||
id: AccountID | AgentID,
|
||||
expectation?: string
|
||||
): AgentID {
|
||||
if (isAgentID(id)) {
|
||||
return id;
|
||||
}
|
||||
@@ -374,13 +418,16 @@ export class LocalNode {
|
||||
);
|
||||
}
|
||||
|
||||
return new Account(
|
||||
return new AccountGroup(
|
||||
coValue.getCurrentContent() as CoMap<GroupContent, AccountMeta>,
|
||||
this
|
||||
).getCurrentAgentID();
|
||||
}
|
||||
|
||||
/** Creates a new group (with the current account as the group's first admin). */
|
||||
/**
|
||||
* Creates a new group (with the current account as the group's first admin).
|
||||
* @category 1. High-level
|
||||
*/
|
||||
createGroup(): Group {
|
||||
const groupCoValue = this.createCoValue({
|
||||
type: "comap",
|
||||
@@ -391,22 +438,22 @@ export class LocalNode {
|
||||
|
||||
let groupContent = expectGroupContent(groupCoValue.getCurrentContent());
|
||||
|
||||
groupContent = groupContent.edit((editable) => {
|
||||
groupContent = groupContent.mutate((editable) => {
|
||||
editable.set(this.account.id, "admin", "trusting");
|
||||
|
||||
const readKey = newRandomKeySecret();
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${this.account.id}`,
|
||||
seal(
|
||||
readKey.secret,
|
||||
this.account.currentSealerSecret(),
|
||||
this.account.currentSealerID(),
|
||||
{
|
||||
seal({
|
||||
message: readKey.secret,
|
||||
from: this.account.currentSealerSecret(),
|
||||
to: this.account.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: groupCoValue.id,
|
||||
tx: groupCoValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
},
|
||||
}),
|
||||
"trusting"
|
||||
);
|
||||
|
||||
@@ -443,7 +490,11 @@ export class LocalNode {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newCoValue = new CoValueCore(entry.coValue.header, newNode, {...entry.coValue.sessions});
|
||||
const newCoValue = new CoValueCore(
|
||||
entry.coValue.header,
|
||||
newNode,
|
||||
{ ...entry.coValue.sessions }
|
||||
);
|
||||
|
||||
newNode.coValues[coValueID as RawCoID] = {
|
||||
state: "loaded",
|
||||
8
packages/cojson/src/media.ts
Normal file
8
packages/cojson/src/media.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { CoMap } from './coValues/coMap.js'
|
||||
import { BinaryCoStream } from './coValues/coStream.js'
|
||||
|
||||
export type ImageDefinition = CoMap<{
|
||||
originalSize: [number, number];
|
||||
placeholderDataURL?: string;
|
||||
[res: `${number}x${number}`]: BinaryCoStream;
|
||||
}>;
|
||||
@@ -1,9 +1,7 @@
|
||||
import { CoID } from "./coValue.js";
|
||||
import { MapOpPayload } from "./coValues/coMap.js";
|
||||
import { JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
KeyID,
|
||||
} from "./crypto.js";
|
||||
import { KeyID } from "./crypto.js";
|
||||
import {
|
||||
CoValueCore,
|
||||
Transaction,
|
||||
@@ -11,10 +9,9 @@ import {
|
||||
accountOrAgentIDfromSessionID,
|
||||
} from "./coValueCore.js";
|
||||
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import {
|
||||
AccountID,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
import { AccountID, Profile } from "./account.js";
|
||||
import { parseJSON } from "./jsonStringify.js";
|
||||
import { expectGroupContent } from "./group.js";
|
||||
|
||||
export type PermissionsDef =
|
||||
| { type: "group"; initialAdmin: AccountID | AgentID }
|
||||
@@ -76,11 +73,30 @@ export function determineValidTransactions(
|
||||
// console.log("before", { memberState, validTransactions });
|
||||
const transactor = accountOrAgentIDfromSessionID(sessionID);
|
||||
|
||||
const change = tx.changes[0] as
|
||||
let changes;
|
||||
|
||||
try {
|
||||
changes = parseJSON(tx.changes);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
coValue.id,
|
||||
"Invalid JSON in transaction",
|
||||
e,
|
||||
tx,
|
||||
JSON.stringify(tx.changes, (k, v) =>
|
||||
k === "changes" || k === "encryptedChanges"
|
||||
? v.slice(0, 20) + "..."
|
||||
: v
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const change = changes[0] as
|
||||
| MapOpPayload<AccountID | AgentID, Role>
|
||||
| MapOpPayload<"readKey", JsonValue>
|
||||
| MapOpPayload<"profile", CoID<Profile>>;
|
||||
if (tx.changes.length !== 1) {
|
||||
if (changes.length !== 1) {
|
||||
console.warn("Group transaction must have exactly one change");
|
||||
continue;
|
||||
}
|
||||
@@ -190,12 +206,14 @@ export function determineValidTransactions(
|
||||
|
||||
return validTransactions;
|
||||
} else if (coValue.header.ruleset.type === "ownedByGroup") {
|
||||
const groupContent = coValue.node
|
||||
.expectCoValueLoaded(
|
||||
coValue.header.ruleset.group,
|
||||
"Determining valid transaction in owned object but its group wasn't loaded"
|
||||
)
|
||||
.getCurrentContent();
|
||||
const groupContent = expectGroupContent(
|
||||
coValue.node
|
||||
.expectCoValueLoaded(
|
||||
coValue.header.ruleset.group,
|
||||
"Determining valid transaction in owned object but its group wasn't loaded"
|
||||
)
|
||||
.getCurrentContent()
|
||||
);
|
||||
|
||||
if (groupContent.type !== "comap") {
|
||||
throw new Error("Group must be a map");
|
||||
@@ -208,10 +226,9 @@ export function determineValidTransactions(
|
||||
);
|
||||
return sessionLog.transactions
|
||||
.filter((tx) => {
|
||||
const transactorRoleAtTxTime = groupContent.getAtTime(
|
||||
transactor,
|
||||
tx.madeAt
|
||||
);
|
||||
const transactorRoleAtTxTime = groupContent
|
||||
.atTime(tx.madeAt)
|
||||
.get(transactor);
|
||||
|
||||
return (
|
||||
transactorRoleAtTxTime === "admin" ||
|
||||
@@ -235,7 +252,7 @@ export function determineValidTransactions(
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Unknown ruleset type " + (coValue.header.ruleset as any).type
|
||||
"Unknown ruleset type " + (coValue.header.ruleset as {type: string}).type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
248
packages/cojson/src/queriedCoValues/queriedCoList.ts
Normal file
248
packages/cojson/src/queriedCoValues/queriedCoList.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { CoList, MutableCoList } from "../coValues/coList.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { Group } from "../group.js";
|
||||
import { isAccountID } from "../account.js";
|
||||
import { AnyCoList, CoID, CoValue } from "../coValue.js";
|
||||
import { TransactionID } from "../ids.js";
|
||||
import { QueriedAccountAndProfile } from "./queriedCoMap.js";
|
||||
import { ValueOrSubQueried, QueryContext } from "../queries.js";
|
||||
|
||||
export class QueriedCoList<L extends AnyCoList> extends Array<
|
||||
ValueOrSubQueried<L["_item"]>
|
||||
> {
|
||||
coList!: L;
|
||||
id!: CoID<L>;
|
||||
type!: "colist";
|
||||
|
||||
/** @internal */
|
||||
constructor(coList: L, queryContext: QueryContext) {
|
||||
if (!(coList instanceof CoList)) {
|
||||
// this might be called from an intrinsic, like map, trying to create an empty array
|
||||
// passing `0` as the only parameter
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Array(coList) as any;
|
||||
}
|
||||
super(
|
||||
...coList
|
||||
.asArray()
|
||||
.map(
|
||||
(item) =>
|
||||
queryContext.resolveValue(item) as ValueOrSubQueried<
|
||||
L["_item"]
|
||||
>
|
||||
)
|
||||
);
|
||||
|
||||
Object.defineProperties(this, {
|
||||
coList: { value: coList },
|
||||
id: { value: coList.id },
|
||||
type: { value: "colist" },
|
||||
edits: {
|
||||
value: [...this.keys()].map((i) => {
|
||||
const edit = coList.editAt(i)!;
|
||||
return {
|
||||
by:
|
||||
edit.by && isAccountID(edit.by)
|
||||
? queryContext.resolveAccount(edit.by)
|
||||
: undefined,
|
||||
tx: edit.tx,
|
||||
at: new Date(edit.at),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: queryContext.resolveValue(edit.value) as any,
|
||||
};
|
||||
}),
|
||||
},
|
||||
deletions: {
|
||||
value: coList.deletionEdits().map((deletion) => ({
|
||||
by:
|
||||
deletion.by && isAccountID(deletion.by)
|
||||
? queryContext.resolveAccount(deletion.by)
|
||||
: undefined,
|
||||
tx: deletion.tx,
|
||||
at: new Date(deletion.at),
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get meta(): L["meta"] {
|
||||
return this.coList.meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.coList.group;
|
||||
}
|
||||
|
||||
get core(): CoValueCore {
|
||||
return this.coList.core;
|
||||
}
|
||||
|
||||
append(
|
||||
item: L["_item"] extends CoValue
|
||||
? L["_item"] | CoID<L["_item"]>
|
||||
: L["_item"],
|
||||
after?: number,
|
||||
privacy?: "private" | "trusting"
|
||||
): L {
|
||||
return this.coList.append(item, after, privacy);
|
||||
}
|
||||
|
||||
prepend(
|
||||
item: L["_item"] extends CoValue
|
||||
? L["_item"] | CoID<L["_item"]>
|
||||
: L["_item"],
|
||||
before?: number,
|
||||
privacy?: "private" | "trusting"
|
||||
): L {
|
||||
return this.coList.prepend(item, before, privacy);
|
||||
}
|
||||
|
||||
delete(at: number, privacy: "private" | "trusting"): L {
|
||||
return this.coList.delete(at, privacy);
|
||||
}
|
||||
|
||||
mutate(
|
||||
mutator: (mutable: MutableCoList<L["_item"], L["meta"]>) => void
|
||||
): L {
|
||||
return this.coList.mutate(mutator);
|
||||
}
|
||||
|
||||
edits!: {
|
||||
by?: QueriedAccountAndProfile;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: L["_item"] extends CoValue
|
||||
? CoID<L["_item"]>
|
||||
: Exclude<L["_item"], CoValue>;
|
||||
}[];
|
||||
|
||||
deletions!: {
|
||||
by?: QueriedAccountAndProfile;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
}[];
|
||||
|
||||
/** @internal */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static isArray(arg: any): arg is any[] {
|
||||
return Array.isArray(arg);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
static from<T>(arrayLike: ArrayLike<T>): T[];
|
||||
/** @internal */
|
||||
static from<T, U>(
|
||||
arrayLike: ArrayLike<T>,
|
||||
mapfn: (v: T, k: number) => U,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
thisArg?: any
|
||||
): U[];
|
||||
/** @internal */
|
||||
static from<T>(iterable: Iterable<T> | ArrayLike<T>): T[];
|
||||
/** @internal */
|
||||
static from<T, U>(
|
||||
iterable: Iterable<T> | ArrayLike<T>,
|
||||
mapfn: (v: T, k: number) => U,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
thisArg?: any
|
||||
): U[];
|
||||
/** @internal */
|
||||
static from<T, U>(
|
||||
_iterable: unknown,
|
||||
_mapfn?: unknown,
|
||||
_thisArg?: unknown
|
||||
): T[] | U[] | T[] | U[] {
|
||||
throw new Error("Array method 'from' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
static of<T>(..._items: T[]): T[] {
|
||||
throw new Error("Array method 'of' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
pop(): ValueOrSubQueried<L["_item"]> | undefined {
|
||||
throw new Error("Array method 'pop' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
push(..._items: ValueOrSubQueried<L["_item"]>[]): number {
|
||||
throw new Error("Array method 'push' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
concat(
|
||||
..._items: ConcatArray<ValueOrSubQueried<L["_item"]>>[]
|
||||
): ValueOrSubQueried<L["_item"]>[];
|
||||
/** @internal */
|
||||
concat(
|
||||
..._items: (
|
||||
| ValueOrSubQueried<L["_item"]>
|
||||
| ConcatArray<ValueOrSubQueried<L["_item"]>>
|
||||
)[]
|
||||
): ValueOrSubQueried<L["_item"]>[];
|
||||
/** @internal */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
concat(..._items: any[]): ValueOrSubQueried<L["_item"]>[] {
|
||||
throw new Error("Array method 'concat' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
reverse(): ValueOrSubQueried<L["_item"]>[] {
|
||||
throw new Error(
|
||||
"Array method 'reverse' not supported on QueriedCoList"
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
shift(): ValueOrSubQueried<L["_item"]> | undefined {
|
||||
throw new Error("Array method 'shift' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
sort(
|
||||
_compareFn?:
|
||||
| ((
|
||||
a: ValueOrSubQueried<L["_item"]>,
|
||||
b: ValueOrSubQueried<L["_item"]>
|
||||
) => number)
|
||||
| undefined
|
||||
): this {
|
||||
throw new Error("Array method 'sort' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
splice(
|
||||
_start: number,
|
||||
_deleteCount?: number | undefined
|
||||
): ValueOrSubQueried<L["_item"]>[] {
|
||||
throw new Error("Array method 'splice' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
unshift(..._items: ValueOrSubQueried<L["_item"]>[]): number {
|
||||
throw new Error(
|
||||
"Array method 'unshift' not supported on QueriedCoList"
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
fill(
|
||||
_value: ValueOrSubQueried<L["_item"]>,
|
||||
_start?: number | undefined,
|
||||
_end?: number | undefined
|
||||
): this {
|
||||
throw new Error("Array method 'fill' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
copyWithin(
|
||||
_target: number,
|
||||
_start: number,
|
||||
_end?: number | undefined
|
||||
): this {
|
||||
throw new Error(
|
||||
"Array method 'copyWithin' not supported on QueriedCoList"
|
||||
);
|
||||
}
|
||||
}
|
||||
180
packages/cojson/src/queriedCoValues/queriedCoMap.ts
Normal file
180
packages/cojson/src/queriedCoValues/queriedCoMap.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { MutableCoMap } from "../coValues/coMap.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { Group } from "../group.js";
|
||||
import { Account, AccountID, Profile, isAccountID } from "../account.js";
|
||||
import { AnyCoMap, CoID, CoValue } from "../coValue.js";
|
||||
import { TransactionID } from "../ids.js";
|
||||
import { ValueOrSubQueried, QueryContext } from "../queries.js";
|
||||
|
||||
export type QueriedCoMap<M extends AnyCoMap> = {
|
||||
[K in keyof M["_shape"] & string]: ValueOrSubQueried<M["_shape"][K]>;
|
||||
} & QueriedCoMapBase<M>;
|
||||
|
||||
export type QueriedCoMapEdit<
|
||||
M extends AnyCoMap,
|
||||
K extends keyof M["_shape"]
|
||||
> = {
|
||||
by?: QueriedAccountAndProfile;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: M["_shape"][K] extends CoValue
|
||||
? CoID<M["_shape"][K]>
|
||||
: Exclude<M["_shape"][K], CoValue>;
|
||||
};
|
||||
|
||||
export class QueriedCoMapBase<M extends AnyCoMap> {
|
||||
coMap!: M;
|
||||
id!: CoID<M>;
|
||||
type!: "comap";
|
||||
|
||||
/** @internal */
|
||||
static newWithKVPairs<M extends AnyCoMap>(
|
||||
coMap: M,
|
||||
queryContext: QueryContext
|
||||
): QueriedCoMap<M> {
|
||||
const kv = {} as {
|
||||
[K in keyof M["_shape"] & string]: ValueOrSubQueried<
|
||||
M["_shape"][K]
|
||||
>;
|
||||
};
|
||||
|
||||
if (coMap.meta?.type === "account") {
|
||||
const profileID = coMap.get("profile");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(kv as any).profile =
|
||||
profileID && queryContext.resolveValue(profileID);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(kv as any).isMe =
|
||||
(coMap as unknown as Account).id ===
|
||||
queryContext.node.account.id;
|
||||
} else {
|
||||
for (const key of coMap.keys()) {
|
||||
const value = coMap.get(key);
|
||||
|
||||
if (value === undefined) continue;
|
||||
|
||||
kv[key as keyof typeof kv] = queryContext.resolveValue(
|
||||
value
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.assign(new QueriedCoMapBase(coMap, queryContext), kv);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
constructor(coMap: M, queryContext: QueryContext) {
|
||||
Object.defineProperties(this, {
|
||||
coMap: { value: coMap, enumerable: false },
|
||||
id: { value: coMap.id, enumerable: false },
|
||||
type: { value: "comap", enumerable: false },
|
||||
edits: {
|
||||
value: Object.fromEntries(
|
||||
coMap.keys().flatMap((key) => {
|
||||
const edits = [...coMap.editsAt(key)].map((edit) => ({
|
||||
by:
|
||||
edit.by && isAccountID(edit.by)
|
||||
? queryContext.resolveAccount(edit.by)
|
||||
: undefined,
|
||||
tx: edit.tx,
|
||||
at: new Date(edit.at),
|
||||
value:
|
||||
edit.value &&
|
||||
queryContext.resolveValue(edit.value),
|
||||
}));
|
||||
const lastEdit = edits[edits.length - 1];
|
||||
if (!lastEdit) return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const editsAtKey = {
|
||||
by: lastEdit.by,
|
||||
tx: lastEdit.tx,
|
||||
at: lastEdit.at,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: lastEdit.value as any,
|
||||
all: edits,
|
||||
};
|
||||
|
||||
return [[key, editsAtKey]];
|
||||
})
|
||||
),
|
||||
enumerable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
edits!: {
|
||||
[K in keyof M["_shape"] & string]:
|
||||
| (QueriedCoMapEdit<M, K> & {
|
||||
all: QueriedCoMapEdit<M, K>[];
|
||||
})
|
||||
| undefined;
|
||||
};
|
||||
|
||||
get meta(): M["meta"] {
|
||||
return this.coMap.meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.coMap.group;
|
||||
}
|
||||
|
||||
get core(): CoValueCore {
|
||||
return this.coMap.core;
|
||||
}
|
||||
|
||||
set<K extends keyof M["_shape"] & string>(
|
||||
key: K,
|
||||
value: M["_shape"][K] extends CoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K],
|
||||
privacy?: "private" | "trusting"
|
||||
): M;
|
||||
set(
|
||||
kv: {
|
||||
[K in keyof M["_shape"] & string]?: M["_shape"][K] extends CoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K];
|
||||
},
|
||||
privacy?: "private" | "trusting"
|
||||
): M;
|
||||
set<K extends keyof M["_shape"] & string>(
|
||||
...args:
|
||||
| [
|
||||
{
|
||||
[K in keyof M["_shape"] &
|
||||
string]?: M["_shape"][K] extends CoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K];
|
||||
},
|
||||
("private" | "trusting")?
|
||||
]
|
||||
| [
|
||||
K,
|
||||
M["_shape"][K] extends CoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K],
|
||||
("private" | "trusting")?
|
||||
]
|
||||
): M {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return (this.coMap.set as Function)(...args);
|
||||
}
|
||||
delete(
|
||||
key: keyof M["_shape"] & string,
|
||||
privacy?: "private" | "trusting"
|
||||
): M {
|
||||
return this.coMap.delete(key, privacy);
|
||||
}
|
||||
mutate(
|
||||
mutator: (mutable: MutableCoMap<M["_shape"], M["meta"]>) => void
|
||||
): M {
|
||||
return this.coMap.mutate(mutator);
|
||||
}
|
||||
}
|
||||
|
||||
export type QueriedAccountAndProfile = {
|
||||
profile?: { name?: string; id: CoID<Profile> };
|
||||
isMe?: boolean;
|
||||
id: AccountID;
|
||||
};
|
||||
125
packages/cojson/src/queriedCoValues/queriedCoStream.ts
Normal file
125
packages/cojson/src/queriedCoValues/queriedCoStream.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { JsonValue } from "../jsonValue.js";
|
||||
import { MutableCoStream } from "../coValues/coStream.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { Group } from "../group.js";
|
||||
import { AccountID, isAccountID } from "../account.js";
|
||||
import { AnyCoStream, CoID, CoValue } from "../coValue.js";
|
||||
import { SessionID, TransactionID } from "../ids.js";
|
||||
import { QueriedAccountAndProfile } from "./queriedCoMap.js";
|
||||
import { ValueOrSubQueried, QueryContext } from "../queries.js";
|
||||
|
||||
|
||||
export type QueriedCoStreamItems<Item extends JsonValue | CoValue> = {
|
||||
last?: ValueOrSubQueried<Item>;
|
||||
by?: QueriedAccountAndProfile;
|
||||
tx?: TransactionID;
|
||||
at?: Date;
|
||||
all: {
|
||||
value: ValueOrSubQueried<Item>;
|
||||
by?: QueriedAccountAndProfile;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
}[];
|
||||
};
|
||||
|
||||
export class QueriedCoStream<S extends AnyCoStream> {
|
||||
coStream: S;
|
||||
id: CoID<S>;
|
||||
type = "costream" as const;
|
||||
|
||||
/** @internal */
|
||||
constructor(coStream: S, queryContext: QueryContext) {
|
||||
this.coStream = coStream;
|
||||
this.id = coStream.id;
|
||||
|
||||
this.perSession = Object.fromEntries(
|
||||
coStream.sessions().map((sessionID) => {
|
||||
const items = [...coStream.itemsIn(sessionID)].map((item) => ({
|
||||
by: item.by && isAccountID(item.by)
|
||||
? queryContext.resolveAccount(item.by)
|
||||
: undefined,
|
||||
tx: item.tx,
|
||||
at: new Date(item.at),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: queryContext.resolveValue(item.value) as any,
|
||||
}));
|
||||
|
||||
const lastItem = items[items.length - 1];
|
||||
|
||||
return [
|
||||
sessionID,
|
||||
{
|
||||
last: lastItem?.value,
|
||||
by: lastItem?.by,
|
||||
tx: lastItem?.tx,
|
||||
at: lastItem?.at,
|
||||
all: items,
|
||||
} satisfies QueriedCoStreamItems<S["_item"]>,
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
this.perAccount = Object.fromEntries(
|
||||
[...coStream.accounts()].map((accountID) => {
|
||||
const items = [...coStream.itemsBy(accountID)].map((item) => ({
|
||||
by: item.by && isAccountID(item.by)
|
||||
? queryContext.resolveAccount(item.by)
|
||||
: undefined,
|
||||
tx: item.tx,
|
||||
at: new Date(item.at),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: queryContext.resolveValue(item.value) as any,
|
||||
}));
|
||||
|
||||
const lastItem = items[items.length - 1];
|
||||
|
||||
return [
|
||||
accountID,
|
||||
{
|
||||
last: lastItem?.value,
|
||||
by: lastItem?.by,
|
||||
tx: lastItem?.tx,
|
||||
at: lastItem?.at,
|
||||
all: items,
|
||||
} satisfies QueriedCoStreamItems<S["_item"]>,
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
this.me = isAccountID(queryContext.node.account.id)
|
||||
? this.perAccount[queryContext.node.account.id]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
get meta(): S["meta"] {
|
||||
return this.coStream.meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.coStream.group;
|
||||
}
|
||||
|
||||
get core(): CoValueCore {
|
||||
return this.coStream.core;
|
||||
}
|
||||
|
||||
me?: QueriedCoStreamItems<S["_item"]>;
|
||||
perAccount: {
|
||||
[account: AccountID]: QueriedCoStreamItems<S["_item"]>;
|
||||
};
|
||||
perSession: {
|
||||
[session: SessionID]: QueriedCoStreamItems<S["_item"]>;
|
||||
};
|
||||
|
||||
push(
|
||||
item: S["_item"] extends CoValue ? S["_item"] | CoID<S["_item"]> : S["_item"],
|
||||
privacy?: "private" | "trusting"
|
||||
): S {
|
||||
return this.coStream.push(item, privacy);
|
||||
}
|
||||
mutate(
|
||||
mutator: (mutable: MutableCoStream<S["_item"], S["meta"]>) => void
|
||||
): S {
|
||||
return this.coStream.mutate(mutator);
|
||||
}
|
||||
}
|
||||
142
packages/cojson/src/queries.ts
Normal file
142
packages/cojson/src/queries.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { JsonValue } from "./jsonValue.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { CoStream } from "./coValues/coStream.js";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import { AccountID } from "./account.js";
|
||||
import { AnyCoList, AnyCoMap, AnyCoStream, CoID, CoValue } from "./coValue.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import {
|
||||
QueriedAccountAndProfile,
|
||||
QueriedCoMap,
|
||||
QueriedCoMapBase,
|
||||
} from "./queriedCoValues/queriedCoMap.js";
|
||||
import { QueriedCoList } from "./queriedCoValues/queriedCoList.js";
|
||||
import { QueriedCoStream } from "./queriedCoValues/queriedCoStream.js";
|
||||
|
||||
export type Queried<T extends CoValue> = T extends AnyCoMap
|
||||
? QueriedCoMap<T>
|
||||
: T extends AnyCoList
|
||||
? QueriedCoList<T>
|
||||
: T extends AnyCoStream
|
||||
? T["meta"] extends { type: "binary" }
|
||||
? never
|
||||
: QueriedCoStream<T>
|
||||
: never;
|
||||
|
||||
export type ValueOrSubQueried<
|
||||
V extends JsonValue | CoValue | CoID<CoValue> | undefined
|
||||
> = V extends CoID<infer C>
|
||||
? Queried<C> | undefined
|
||||
: V extends CoValue
|
||||
? Queried<V> | undefined
|
||||
: V;
|
||||
|
||||
export interface CleanupCallbackAndUsable {
|
||||
(): void;
|
||||
[Symbol.dispose]: () => void;
|
||||
}
|
||||
|
||||
export class QueryContext {
|
||||
values: {
|
||||
[id: CoID<CoValue>]: {
|
||||
lastQueried: Queried<CoValue> | undefined;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
} = {};
|
||||
node: LocalNode;
|
||||
onUpdate: () => void;
|
||||
|
||||
constructor(node: LocalNode, onUpdate: () => void) {
|
||||
this.node = node;
|
||||
this.onUpdate = onUpdate;
|
||||
}
|
||||
|
||||
getChildLastQueriedOrSubscribe<T extends CoValue>(valueID: CoID<T>) {
|
||||
let value = this.values[valueID];
|
||||
if (!value) {
|
||||
value = {
|
||||
lastQueried: undefined,
|
||||
unsubscribe: query(valueID, this.node, (childQueried) => {
|
||||
value!.lastQueried = childQueried as Queried<CoValue>;
|
||||
this.onUpdate();
|
||||
}),
|
||||
};
|
||||
this.values[valueID] = value;
|
||||
}
|
||||
return value.lastQueried as Queried<T> | undefined;
|
||||
}
|
||||
|
||||
resolveAccount(accountID: AccountID) {
|
||||
return this.getChildLastQueriedOrSubscribe(
|
||||
accountID
|
||||
) as QueriedAccountAndProfile;
|
||||
}
|
||||
|
||||
resolveValue<T extends JsonValue>(
|
||||
value: T
|
||||
): T extends CoID<infer C> ? Queried<C> | undefined : T {
|
||||
return (
|
||||
typeof value === "string" && value.startsWith("co_")
|
||||
? this.getChildLastQueriedOrSubscribe(value as CoID<CoValue>)
|
||||
: value
|
||||
) as T extends CoID<infer C> ? Queried<C> | undefined : T;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const child of Object.values(this.values)) {
|
||||
child.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function query<T extends CoValue>(
|
||||
id: CoID<T>,
|
||||
node: LocalNode,
|
||||
callback: (queried: Queried<T> | undefined) => void,
|
||||
parentContext?: QueryContext
|
||||
): CleanupCallbackAndUsable {
|
||||
console.log("querying", id);
|
||||
|
||||
const context = parentContext || new QueryContext(node, onUpdate);
|
||||
|
||||
const unsubscribe = node.subscribe(id, (update) => {
|
||||
lastRootValue = update;
|
||||
onUpdate();
|
||||
});
|
||||
|
||||
let lastRootValue: T | undefined;
|
||||
|
||||
function onUpdate() {
|
||||
const rootValue = lastRootValue;
|
||||
|
||||
if (rootValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rootValue instanceof CoMap) {
|
||||
callback(
|
||||
QueriedCoMapBase.newWithKVPairs(
|
||||
rootValue,
|
||||
context
|
||||
) as Queried<T>
|
||||
);
|
||||
} else if (rootValue instanceof CoList) {
|
||||
callback(new QueriedCoList(rootValue, context) as Queried<T>);
|
||||
} else if (rootValue instanceof CoStream) {
|
||||
if (rootValue.meta?.type === "binary") {
|
||||
// Querying binary string not yet implemented
|
||||
return {};
|
||||
} else {
|
||||
callback(new QueriedCoStream(rootValue, context) as Queried<T>);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = function cleanup() {
|
||||
context.cleanup();
|
||||
unsubscribe();
|
||||
} as CleanupCallbackAndUsable;
|
||||
cleanup[Symbol.dispose] = cleanup;
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
@@ -34,7 +34,14 @@ export function connectedPeers(
|
||||
trace &&
|
||||
console.debug(
|
||||
`${peer2id} -> ${peer1id}`,
|
||||
JSON.stringify(chunk, null, 2)
|
||||
JSON.stringify(
|
||||
chunk,
|
||||
(k, v) =>
|
||||
(k === "changes" || k === "encryptedChanges")
|
||||
? v.slice(0, 20) + "..."
|
||||
: v,
|
||||
2
|
||||
)
|
||||
);
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
@@ -52,7 +59,14 @@ export function connectedPeers(
|
||||
trace &&
|
||||
console.debug(
|
||||
`${peer1id} -> ${peer2id}`,
|
||||
JSON.stringify(chunk, null, 2)
|
||||
JSON.stringify(
|
||||
chunk,
|
||||
(k, v) =>
|
||||
(k === "changes" || k === "encryptedChanges")
|
||||
? v.slice(0, 20) + "..."
|
||||
: v,
|
||||
2
|
||||
)
|
||||
);
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
@@ -102,16 +116,22 @@ export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
|
||||
},
|
||||
});
|
||||
|
||||
let lastWritePromise = Promise.resolve();
|
||||
|
||||
const writable = new WritableStream<T>({
|
||||
async write(chunk) {
|
||||
const enqueue = await enqueuePromise;
|
||||
if (readerClosed) {
|
||||
throw new Error("Reader closed");
|
||||
} else {
|
||||
// make sure write resolves before corresponding read
|
||||
setTimeout(() => {
|
||||
enqueue(chunk);
|
||||
})
|
||||
// make sure write resolves before corresponding read, but make sure writes are still in order
|
||||
await lastWritePromise;
|
||||
lastWritePromise = new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
enqueue(chunk);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
async abort(reason) {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Signature } from "./crypto.js";
|
||||
import { CoValueHeader, Transaction } from "./coValueCore.js";
|
||||
import { CoValueCore } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { newLoadingState } from "./node.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import { newLoadingState } from "./localNode.js";
|
||||
import {
|
||||
ReadableStream,
|
||||
WritableStream,
|
||||
WritableStreamDefaultWriter,
|
||||
} from "isomorphic-streams";
|
||||
import { RawCoID, SessionID } from "./ids.js";
|
||||
import { stableStringify } from "./fastJsonStableStringify.js";
|
||||
|
||||
export type CoValueKnownState = {
|
||||
id: RawCoID;
|
||||
@@ -215,14 +214,32 @@ export class SyncManager {
|
||||
await this.sendNewContentIncludingDependencies(id, peer);
|
||||
}
|
||||
|
||||
const newContent = coValue.newContentSince(
|
||||
const newContentPieces = coValue.newContentSince(
|
||||
peer.optimisticKnownStates[id]
|
||||
);
|
||||
|
||||
if (newContent) {
|
||||
await this.trySendToPeer(peer, newContent);
|
||||
if (newContentPieces) {
|
||||
const optimisticKnownStateBefore =
|
||||
peer.optimisticKnownStates[id] || emptyKnownState(id);
|
||||
|
||||
const sendPieces = async () => {
|
||||
for (const [_i, piece] of newContentPieces.entries()) {
|
||||
// console.log(
|
||||
// `${id} -> ${peer.id}: Sending content piece ${i + 1}/${newContentPieces.length} header: ${!!piece.header}`,
|
||||
// // Object.values(piece.new).map((s) => s.newTransactions)
|
||||
// );
|
||||
await this.trySendToPeer(peer, piece);
|
||||
}
|
||||
};
|
||||
|
||||
sendPieces().catch((e) => {
|
||||
console.error("Error sending new content piece, retrying", e);
|
||||
peer.optimisticKnownStates[id] = optimisticKnownStateBefore;
|
||||
return this.sendNewContentIncludingDependencies(id, peer);
|
||||
});
|
||||
|
||||
peer.optimisticKnownStates[id] = combinedKnownStates(
|
||||
peer.optimisticKnownStates[id] || emptyKnownState(id),
|
||||
optimisticKnownStateBefore,
|
||||
coValue.knownState()
|
||||
);
|
||||
}
|
||||
@@ -261,15 +278,21 @@ export class SyncManager {
|
||||
for await (const msg of peerState.incoming) {
|
||||
try {
|
||||
await this.handleSyncMessage(msg, peerState);
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error reading from peer ${peer.id}, handling msg`,
|
||||
JSON.stringify(msg),
|
||||
JSON.stringify(msg, (k, v) =>
|
||||
k === "changes" || k === "encryptedChanges"
|
||||
? v.slice(0, 20) + "..."
|
||||
: v
|
||||
),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log("DONE!!!");
|
||||
} catch (e) {
|
||||
console.error(`Error reading from peer ${peer.id}`, e);
|
||||
}
|
||||
@@ -446,6 +469,10 @@ export class SyncManager {
|
||||
const newTransactions =
|
||||
newContentForSession.newTransactions.slice(alreadyKnownOffset);
|
||||
|
||||
if (newTransactions.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = performance.now();
|
||||
const success = await coValue.tryAddTransactionsAsync(
|
||||
sessionID,
|
||||
@@ -455,20 +482,34 @@ export class SyncManager {
|
||||
);
|
||||
const after = performance.now();
|
||||
if (after - before > 10) {
|
||||
const totalTxLength = newTransactions.map(t => stableStringify(t)!.length).reduce((a, b) => a + b, 0);
|
||||
const totalTxLength = newTransactions
|
||||
.map((t) =>
|
||||
t.privacy === "private"
|
||||
? t.encryptedChanges.length
|
||||
: t.changes.length
|
||||
)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
console.log(
|
||||
"Adding incoming transactions took",
|
||||
after - before,
|
||||
"ms",
|
||||
totalTxLength,
|
||||
"bytes = ",
|
||||
"bandwidth: MB/s",
|
||||
(1000 * totalTxLength / (after - before)) / (1024 * 1024)
|
||||
`Adding incoming transactions took ${(
|
||||
after - before
|
||||
).toFixed(2)}ms for ${totalTxLength} bytes = bandwidth: ${(
|
||||
(1000 * totalTxLength) /
|
||||
(after - before) /
|
||||
(1024 * 1024)
|
||||
).toFixed(2)} MB/s`
|
||||
);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
console.error("Failed to add transactions", newTransactions);
|
||||
console.error(
|
||||
"Failed to add transactions",
|
||||
msg.id,
|
||||
JSON.stringify(newTransactions, (k, v) =>
|
||||
k === "changes" || k === "encryptedChanges"
|
||||
? v.slice(0, 20) + "..."
|
||||
: v
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -493,18 +534,9 @@ export class SyncManager {
|
||||
}
|
||||
|
||||
async handleCorrection(msg: KnownStateMessage, peer: PeerState) {
|
||||
const coValue = this.local.expectCoValueLoaded(msg.id);
|
||||
peer.optimisticKnownStates[msg.id] = msg;
|
||||
|
||||
peer.optimisticKnownStates[msg.id] = combinedKnownStates(
|
||||
msg,
|
||||
coValue.knownState()
|
||||
);
|
||||
|
||||
const newContent = coValue.newContentSince(msg);
|
||||
|
||||
if (newContent) {
|
||||
await this.trySendToPeer(peer, newContent);
|
||||
}
|
||||
return this.sendNewContentIncludingDependencies(msg.id, peer);
|
||||
}
|
||||
|
||||
handleUnsubscribe(_msg: DoneMessage) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { newRandomSessionID } from "./coValueCore.js";
|
||||
import { cojsonReady } from "./index.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
import { newRandomSessionID } from "../coValueCore.js";
|
||||
import { cojsonReady } from "../index.js";
|
||||
import { LocalNode } from "../localNode.js";
|
||||
import { connectedPeers } from "../streamUtils.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
@@ -19,9 +19,6 @@ test("Can create a node while creating a new account with profile", async () =>
|
||||
expect(node.expectProfileLoaded(accountID).get("name")).toEqual(
|
||||
"Hermes Puggington"
|
||||
);
|
||||
expect((await node.loadProfile(accountID)).get("name")).toEqual(
|
||||
"Hermes Puggington"
|
||||
);
|
||||
});
|
||||
|
||||
test("A node with an account can create groups and and objects within them", async () => {
|
||||
@@ -39,7 +36,7 @@ test("A node with an account can create groups and and objects within them", asy
|
||||
|
||||
expect(map.get("foo")).toEqual("bar");
|
||||
|
||||
expect(map.whoEdited("foo")).toEqual(accountID);
|
||||
expect(map.lastEditAt("foo")?.by).toEqual(accountID);
|
||||
});
|
||||
|
||||
test("Can create account with one node, and then load it on another", async () => {
|
||||
@@ -57,7 +54,7 @@ test("Can create account with one node, and then load it on another", async () =
|
||||
|
||||
const [node1asPeer, node2asPeer] = connectedPeers("node1", "node2", {trace: true, peer1role: "server", peer2role: "client"});
|
||||
|
||||
node.sync.addPeer(node2asPeer);
|
||||
node.syncManager.addPeer(node2asPeer);
|
||||
|
||||
const node2 = await LocalNode.withLoadedAccount(
|
||||
accountID,
|
||||
@@ -1,8 +1,9 @@
|
||||
import { accountOrAgentIDfromSessionID } from "./coValueCore.js";
|
||||
import { BinaryCoStream } from "./coValues/coStream.js";
|
||||
import { createdNowUnique } from "./crypto.js";
|
||||
import { cojsonReady } from "./index.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { expectList, expectMap, expectStream } from "../coValue.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { BinaryCoStream } from "../coValues/coStream.js";
|
||||
import { createdNowUnique } from "../crypto.js";
|
||||
import { MAX_RECOMMENDED_TX_SIZE, cojsonReady } from "../index.js";
|
||||
import { LocalNode } from "../localNode.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -19,11 +20,7 @@ test("Empty CoMap works", () => {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
const content = expectMap(coValue.getCurrentContent());
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
expect([...content.keys()]).toEqual([]);
|
||||
@@ -40,11 +37,7 @@ test("Can insert and delete CoMap entries in edit()", () => {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
const content = expectMap(coValue.getCurrentContent());
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
@@ -69,11 +62,7 @@ test("Can get CoMap entry values at different points in time", () => {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
const content = expectMap(coValue.getCurrentContent());
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
@@ -88,10 +77,10 @@ test("Can get CoMap entry values at different points in time", () => {
|
||||
while (Date.now() < beforeC + 10) {}
|
||||
editable.set("hello", "C", "trusting");
|
||||
expect(editable.get("hello")).toEqual("C");
|
||||
expect(editable.getAtTime("hello", Date.now())).toEqual("C");
|
||||
expect(editable.getAtTime("hello", beforeA)).toEqual(undefined);
|
||||
expect(editable.getAtTime("hello", beforeB)).toEqual("A");
|
||||
expect(editable.getAtTime("hello", beforeC)).toEqual("B");
|
||||
expect(editable.atTime(Date.now()).get("hello")).toEqual("C");
|
||||
expect(editable.atTime(beforeA).get("hello")).toEqual(undefined);
|
||||
expect(editable.atTime(beforeB).get("hello")).toEqual("A");
|
||||
expect(editable.atTime(beforeC).get("hello")).toEqual("B");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,43 +94,43 @@ test("Can get all historic values of key in CoMap", () => {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
const content = expectMap(coValue.getCurrentContent());
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set("hello", "A", "trusting");
|
||||
const txA = editable.getLastTxID("hello");
|
||||
const editA = editable.lastEditAt("hello");
|
||||
editable.set("hello", "B", "trusting");
|
||||
const txB = editable.getLastTxID("hello");
|
||||
const editB = editable.lastEditAt("hello");
|
||||
editable.delete("hello", "trusting");
|
||||
const txDel = editable.getLastTxID("hello");
|
||||
const editDel = editable.lastEditAt("hello");
|
||||
editable.set("hello", "C", "trusting");
|
||||
const txC = editable.getLastTxID("hello");
|
||||
expect(editable.getHistory("hello")).toEqual([
|
||||
const editC = editable.lastEditAt("hello");
|
||||
expect([...editable.editsAt("hello")]).toEqual([
|
||||
{
|
||||
txID: txA,
|
||||
tx: editA!.tx,
|
||||
by: node.account.id,
|
||||
value: "A",
|
||||
at: txA && coValue.getTx(txA)?.madeAt,
|
||||
at: editA?.at,
|
||||
},
|
||||
{
|
||||
txID: txB,
|
||||
tx: editB!.tx,
|
||||
by: node.account.id,
|
||||
value: "B",
|
||||
at: txB && coValue.getTx(txB)?.madeAt,
|
||||
at: editB?.at,
|
||||
},
|
||||
{
|
||||
txID: txDel,
|
||||
tx: editDel!.tx,
|
||||
by: node.account.id,
|
||||
value: undefined,
|
||||
at: txDel && coValue.getTx(txDel)?.madeAt,
|
||||
at: editDel?.at,
|
||||
},
|
||||
{
|
||||
txID: txC,
|
||||
tx: editC!.tx,
|
||||
by: node.account.id,
|
||||
value: "C",
|
||||
at: txC && coValue.getTx(txC)?.madeAt,
|
||||
at: editC?.at,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -157,26 +146,22 @@ test("Can get last tx ID for a key in CoMap", () => {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
const content = expectMap(coValue.getCurrentContent());
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
expect(editable.getLastTxID("hello")).toEqual(undefined);
|
||||
expect(editable.lastEditAt("hello")).toEqual(undefined);
|
||||
editable.set("hello", "A", "trusting");
|
||||
const sessionID = editable.getLastTxID("hello")?.sessionID;
|
||||
const sessionID = editable.lastEditAt("hello")?.tx.sessionID;
|
||||
expect(sessionID && accountOrAgentIDfromSessionID(sessionID)).toEqual(
|
||||
node.account.id
|
||||
);
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(0);
|
||||
expect(editable.lastEditAt("hello")?.tx.txIndex).toEqual(0);
|
||||
editable.set("hello", "B", "trusting");
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(1);
|
||||
expect(editable.lastEditAt("hello")?.tx.txIndex).toEqual(1);
|
||||
editable.set("hello", "C", "trusting");
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(2);
|
||||
expect(editable.lastEditAt("hello")?.tx.txIndex).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -190,11 +175,7 @@ test("Empty CoList works", () => {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
const content = expectList(coValue.getCurrentContent());
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
expect(content.toJSON()).toEqual([]);
|
||||
@@ -210,22 +191,16 @@ test("Can append, prepend and delete items to CoList", () => {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
const content = expectList(coValue.getCurrentContent());
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.append(0, "hello", "trusting");
|
||||
editable.append("hello", 0, "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello"]);
|
||||
editable.append(0, "world", "trusting");
|
||||
editable.append("world", 0, "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "world"]);
|
||||
editable.prepend(1, "beautiful", "trusting");
|
||||
editable.prepend("beautiful", 1, "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "beautiful", "world"]);
|
||||
editable.prepend(3, "hooray", "trusting");
|
||||
editable.prepend("hooray", 3, "trusting");
|
||||
expect(editable.toJSON()).toEqual([
|
||||
"hello",
|
||||
"beautiful",
|
||||
@@ -247,20 +222,16 @@ test("Push is equivalent to append after last item", () => {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
const content = expectList(coValue.getCurrentContent());
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.append(0, "hello", "trusting");
|
||||
editable.append("hello", 0, "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello"]);
|
||||
editable.push("world", "trusting");
|
||||
editable.append("world", undefined, "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "world"]);
|
||||
editable.push("hooray", "trusting");
|
||||
editable.append("hooray", undefined, "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "world", "hooray"]);
|
||||
});
|
||||
});
|
||||
@@ -275,16 +246,12 @@ test("Can push into empty list", () => {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
const content = expectList(coValue.getCurrentContent());
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.push("hello", "trusting");
|
||||
editable.append("hello", undefined, "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello"]);
|
||||
});
|
||||
});
|
||||
@@ -299,11 +266,7 @@ test("Empty CoStream works", () => {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "costream") {
|
||||
throw new Error("Expected stream");
|
||||
}
|
||||
const content = expectStream(coValue.getCurrentContent());
|
||||
|
||||
expect(content.type).toEqual("costream");
|
||||
expect(content.toJSON()).toEqual({});
|
||||
@@ -320,11 +283,7 @@ test("Can push into CoStream", () => {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "costream") {
|
||||
throw new Error("Expected stream");
|
||||
}
|
||||
const content = expectStream(coValue.getCurrentContent());
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.push({ hello: "world" }, "trusting");
|
||||
@@ -354,7 +313,11 @@ test("Empty BinaryCoStream works", () => {
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "costream" || content.meta?.type !== "binary" || !(content instanceof BinaryCoStream)) {
|
||||
if (
|
||||
content.type !== "costream" ||
|
||||
content.meta?.type !== "binary" ||
|
||||
!(content instanceof BinaryCoStream)
|
||||
) {
|
||||
throw new Error("Expected binary stream");
|
||||
}
|
||||
|
||||
@@ -376,20 +339,27 @@ test("Can push into BinaryCoStream", () => {
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "costream" || content.meta?.type !== "binary" || !(content instanceof BinaryCoStream)) {
|
||||
if (
|
||||
content.type !== "costream" ||
|
||||
content.meta?.type !== "binary" ||
|
||||
!(content instanceof BinaryCoStream)
|
||||
) {
|
||||
throw new Error("Expected binary stream");
|
||||
}
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.startBinaryStream({mimeType: "text/plain", fileName: "test.txt"}, "trusting");
|
||||
expect(editable.getBinaryChunks()).toEqual({
|
||||
editable.startBinaryStream(
|
||||
{ mimeType: "text/plain", fileName: "test.txt" },
|
||||
"trusting"
|
||||
);
|
||||
expect(editable.getBinaryChunks(true)).toEqual({
|
||||
mimeType: "text/plain",
|
||||
fileName: "test.txt",
|
||||
chunks: [],
|
||||
finished: false,
|
||||
});
|
||||
editable.pushBinaryStreamChunk(new Uint8Array([1, 2, 3]), "trusting");
|
||||
expect(editable.getBinaryChunks()).toEqual({
|
||||
expect(editable.getBinaryChunks(true)).toEqual({
|
||||
mimeType: "text/plain",
|
||||
fileName: "test.txt",
|
||||
chunks: [new Uint8Array([1, 2, 3])],
|
||||
@@ -397,7 +367,7 @@ test("Can push into BinaryCoStream", () => {
|
||||
});
|
||||
editable.pushBinaryStreamChunk(new Uint8Array([4, 5, 6]), "trusting");
|
||||
|
||||
expect(editable.getBinaryChunks()).toEqual({
|
||||
expect(editable.getBinaryChunks(true)).toEqual({
|
||||
mimeType: "text/plain",
|
||||
fileName: "test.txt",
|
||||
chunks: [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])],
|
||||
@@ -413,3 +383,148 @@ test("Can push into BinaryCoStream", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("When adding large transactions (small fraction of MAX_RECOMMENDED_TX_SIZE), we store an inbetween signature every time we reach MAX_RECOMMENDED_TX_SIZE and split up newContentSince accordingly", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: { type: "binary" },
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (
|
||||
content.type !== "costream" ||
|
||||
content.meta?.type !== "binary" ||
|
||||
!(content instanceof BinaryCoStream)
|
||||
) {
|
||||
throw new Error("Expected binary stream");
|
||||
}
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.startBinaryStream(
|
||||
{ mimeType: "text/plain", fileName: "test.txt" },
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const chunk = new Uint8Array(MAX_RECOMMENDED_TX_SIZE / 3 + 100);
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.pushBinaryStreamChunk(chunk, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.endBinaryStream("trusting");
|
||||
});
|
||||
|
||||
const sessionEntry = coValue._sessions[node.currentSessionID]!;
|
||||
expect(sessionEntry.transactions.length).toEqual(12);
|
||||
expect(sessionEntry.signatureAfter[0]).not.toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[1]).not.toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[2]).not.toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[3]).toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[4]).not.toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[5]).not.toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[6]).toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[7]).not.toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[8]).not.toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[9]).toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[10]).not.toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[11]).not.toBeDefined();
|
||||
|
||||
const newContent = coValue.newContentSince({
|
||||
id: coValue.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
})!;
|
||||
|
||||
expect(newContent.length).toEqual(5);
|
||||
expect(newContent[0]!.header).toBeDefined();
|
||||
expect(newContent[1]!.new[node.currentSessionID]!.lastSignature).toEqual(
|
||||
sessionEntry.signatureAfter[3]
|
||||
);
|
||||
expect(newContent[2]!.new[node.currentSessionID]!.lastSignature).toEqual(
|
||||
sessionEntry.signatureAfter[6]
|
||||
);
|
||||
expect(newContent[3]!.new[node.currentSessionID]!.lastSignature).toEqual(
|
||||
sessionEntry.signatureAfter[9]
|
||||
);
|
||||
expect(newContent[4]!.new[node.currentSessionID]!.lastSignature).toEqual(
|
||||
sessionEntry.lastSignature
|
||||
);
|
||||
});
|
||||
|
||||
test("When adding large transactions (bigger than MAX_RECOMMENDED_TX_SIZE), we store an inbetween signature after every large transaction and split up newContentSince accordingly", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: { type: "binary" },
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (
|
||||
content.type !== "costream" ||
|
||||
content.meta?.type !== "binary" ||
|
||||
!(content instanceof BinaryCoStream)
|
||||
) {
|
||||
throw new Error("Expected binary stream");
|
||||
}
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.startBinaryStream(
|
||||
{ mimeType: "text/plain", fileName: "test.txt" },
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const chunk = new Uint8Array(MAX_RECOMMENDED_TX_SIZE + 100);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
content.edit((editable) => {
|
||||
editable.pushBinaryStreamChunk(chunk, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.endBinaryStream("trusting");
|
||||
});
|
||||
|
||||
const sessionEntry = coValue._sessions[node.currentSessionID]!;
|
||||
expect(sessionEntry.transactions.length).toEqual(5);
|
||||
expect(sessionEntry.signatureAfter[0]).not.toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[1]).toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[2]).toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[3]).toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[4]).not.toBeDefined();
|
||||
|
||||
const newContent = coValue.newContentSince({
|
||||
id: coValue.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
})!;
|
||||
|
||||
expect(newContent.length).toEqual(5);
|
||||
expect(newContent[0]!.header).toBeDefined();
|
||||
expect(newContent[1]!.new[node.currentSessionID]!.lastSignature).toEqual(
|
||||
sessionEntry.signatureAfter[1]
|
||||
);
|
||||
expect(newContent[2]!.new[node.currentSessionID]!.lastSignature).toEqual(
|
||||
sessionEntry.signatureAfter[2]
|
||||
);
|
||||
expect(newContent[3]!.new[node.currentSessionID]!.lastSignature).toEqual(
|
||||
sessionEntry.signatureAfter[3]
|
||||
);
|
||||
expect(newContent[4]!.new[node.currentSessionID]!.lastSignature).toEqual(
|
||||
sessionEntry.lastSignature
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Transaction } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
|
||||
import { Transaction } from "../coValueCore.js";
|
||||
import { LocalNode } from "../localNode.js";
|
||||
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "../crypto.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
import { MapOpPayload } from "./coValues/coMap.js";
|
||||
import { Role } from "./permissions.js";
|
||||
import { cojsonReady } from "./index.js";
|
||||
import { MapOpPayload } from "../coValues/coMap.js";
|
||||
import { Role } from "../permissions.js";
|
||||
import { cojsonReady } from "../index.js";
|
||||
import { stableStringify } from "../jsonStringify.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
@@ -24,11 +25,11 @@ test("Can create coValue with new agent credentials and add transaction to it",
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
@@ -61,11 +62,11 @@ test("transactions with wrong signature are rejected", () => {
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
@@ -97,11 +98,11 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
@@ -110,11 +111,11 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
hello: "wrong",
|
||||
},
|
||||
],
|
||||
]),
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -154,13 +155,13 @@ test("New transactions in a group correctly update owned values, including subsc
|
||||
const resignationThatWeJustLearnedAbout = {
|
||||
privacy: "trusting",
|
||||
madeAt: timeBeforeEdit,
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
op: "set",
|
||||
key: account.id,
|
||||
value: "revoked"
|
||||
} satisfies MapOpPayload<typeof account.id, Role>
|
||||
]
|
||||
])
|
||||
} satisfies Transaction;
|
||||
|
||||
const { expectedNewHash } = group.underlyingMap.core.expectedNewHashAfter(sessionID, [
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
decryptForTransaction,
|
||||
encryptKeySecret,
|
||||
decryptKeySecret,
|
||||
} from './crypto.js';
|
||||
} from "../crypto.js";
|
||||
import { base58, base64url } from "@scure/base";
|
||||
import { x25519 } from "@noble/curves/ed25519";
|
||||
import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import stableStringify from "fast-json-stable-stringify";
|
||||
import { SessionID } from './ids.js';
|
||||
import { cojsonReady } from './index.js';
|
||||
import { SessionID } from "../ids.js";
|
||||
import { cojsonReady } from "../index.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
@@ -58,18 +58,18 @@ test("encrypting round-trips, but invalid receiver can't unseal", () => {
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
} as const;
|
||||
|
||||
const sealed = seal(
|
||||
data,
|
||||
sender,
|
||||
getSealerID(sealer),
|
||||
nOnceMaterial
|
||||
);
|
||||
const sealed = seal({
|
||||
message: data,
|
||||
from: sender,
|
||||
to: getSealerID(sealer),
|
||||
nOnceMaterial,
|
||||
});
|
||||
|
||||
expect(
|
||||
unseal(sealed, sealer, getSealerID(sender), nOnceMaterial)
|
||||
).toEqual(data);
|
||||
expect(
|
||||
() => unseal(sealed, wrongSealer, getSealerID(sender), nOnceMaterial)
|
||||
expect(unseal(sealed, sealer, getSealerID(sender), nOnceMaterial)).toEqual(
|
||||
data
|
||||
);
|
||||
expect(() =>
|
||||
unseal(sealed, wrongSealer, getSealerID(sender), nOnceMaterial)
|
||||
).toThrow(/Wrong tag/);
|
||||
|
||||
// trying with wrong sealer secret, by hand
|
||||
@@ -82,9 +82,7 @@ test("encrypting round-trips, but invalid receiver can't unseal", () => {
|
||||
const senderPub = base58.decode(
|
||||
getSealerID(sender).substring("sealer_z".length)
|
||||
);
|
||||
const sealedBytes = base64url.decode(
|
||||
sealed.substring("sealed_U".length)
|
||||
);
|
||||
const sealedBytes = base64url.decode(sealed.substring("sealed_U".length));
|
||||
const sharedSecret = x25519.getSharedSecret(sealer3priv, senderPub);
|
||||
|
||||
expect(() => {
|
||||
@@ -105,7 +103,7 @@ test("Hashing is deterministic", () => {
|
||||
test("Encryption for transactions round-trips", () => {
|
||||
const { secret } = newRandomKeySecret();
|
||||
|
||||
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
@@ -120,7 +118,7 @@ test("Encryption for transactions round-trips", () => {
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret, {
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
@@ -132,7 +130,7 @@ test("Encryption for transactions doesn't decrypt with a wrong key", () => {
|
||||
const { secret } = newRandomKeySecret();
|
||||
const { secret: secret2 } = newRandomKeySecret();
|
||||
|
||||
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
@@ -147,7 +145,7 @@ test("Encryption for transactions doesn't decrypt with a wrong key", () => {
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret2, {
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret2, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LocalNode, CoMap, CoList, CoStream, BinaryCoStream, cojsonReady } from "./index";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils";
|
||||
import { LocalNode, CoMap, CoList, CoStream, BinaryCoStream, cojsonReady } from "../index";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { newRandomSessionID } from "./coValueCore.js";
|
||||
import { expectMap } from "./coValue.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { newRandomSessionID } from "../coValueCore.js";
|
||||
import { expectMap } from "../coValue.js";
|
||||
import { Group, expectGroupContent } from "../group.js";
|
||||
import {
|
||||
createdNowUnique,
|
||||
newRandomKeySecret,
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
getAgentID,
|
||||
getAgentSealerSecret,
|
||||
getAgentSealerID,
|
||||
} from "./crypto.js";
|
||||
} from "../crypto.js";
|
||||
import {
|
||||
newGroup,
|
||||
newGroupHighLevel,
|
||||
groupWithTwoAdmins,
|
||||
groupWithTwoAdminsHighLevel,
|
||||
} from "./testUtils.js";
|
||||
import { AnonymousControlledAccount, cojsonReady } from "./index.js";
|
||||
import { AnonymousControlledAccount, cojsonReady } from "../index.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
@@ -39,7 +39,9 @@ test("Added admin can add a third admin to a group", () => {
|
||||
newRandomSessionID(otherAdmin.id)
|
||||
);
|
||||
|
||||
let otherContent = expectGroupContent(groupAsOtherAdmin.getCurrentContent());
|
||||
let otherContent = expectGroupContent(
|
||||
groupAsOtherAdmin.getCurrentContent()
|
||||
);
|
||||
|
||||
expect(otherContent.get(otherAdmin.id)).toEqual("admin");
|
||||
|
||||
@@ -112,9 +114,9 @@ test("Admins can't demote other admins in a group (high level)", () => {
|
||||
newRandomSessionID(otherAdmin.id)
|
||||
);
|
||||
|
||||
expect(() => groupAsOtherAdmin.addMemberInternal(admin.id, "writer")).toThrow(
|
||||
"Failed to set role"
|
||||
);
|
||||
expect(() =>
|
||||
groupAsOtherAdmin.addMemberInternal(admin.id, "writer")
|
||||
).toThrow("Failed to set role");
|
||||
|
||||
expect(groupAsOtherAdmin.underlyingMap.get(admin.id)).toEqual("admin");
|
||||
});
|
||||
@@ -157,7 +159,9 @@ test("Admins an add writers to a group, who can't add admins, writers, or reader
|
||||
expect(editable.get(otherAgent.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
groupContentAsWriter = expectGroupContent(groupAsWriter.getCurrentContent());
|
||||
groupContentAsWriter = expectGroupContent(
|
||||
groupAsWriter.getCurrentContent()
|
||||
);
|
||||
|
||||
expect(groupContentAsWriter.get(otherAgent.id)).toBeUndefined();
|
||||
});
|
||||
@@ -230,7 +234,9 @@ test("Admins can add readers to a group, who can't add admins, writers, or reade
|
||||
expect(editable.get(otherAgent.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
groupContentAsReader = expectGroupContent(groupAsReader.getCurrentContent());
|
||||
groupContentAsReader = expectGroupContent(
|
||||
groupAsReader.getCurrentContent()
|
||||
);
|
||||
|
||||
expect(groupContentAsReader.get(otherAgent.id)).toBeUndefined();
|
||||
});
|
||||
@@ -425,15 +431,15 @@ test("Admins can set group read key and then use it to create and read private t
|
||||
|
||||
groupContent.edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
|
||||
@@ -492,27 +498,27 @@ test("Admins can set group read key and then writers can use it to create and re
|
||||
editable.set(writer.id, "writer", "trusting");
|
||||
expect(editable.get(writer.id)).toEqual("writer");
|
||||
|
||||
const revelation1 = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation1 = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation1, "trusting");
|
||||
|
||||
const revelation2 = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
writer.currentSealerID(),
|
||||
{
|
||||
const revelation2 = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: writer.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${writer.id}`, revelation2, "trusting");
|
||||
|
||||
@@ -583,27 +589,27 @@ test("Admins can set group read key and then use it to create private transactio
|
||||
editable.set(reader.id, "reader", "trusting");
|
||||
expect(editable.get(reader.id)).toEqual("reader");
|
||||
|
||||
const revelation1 = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation1 = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation1, "trusting");
|
||||
|
||||
const revelation2 = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
reader.currentSealerID(),
|
||||
{
|
||||
const revelation2 = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: reader.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${reader.id}`, revelation2, "trusting");
|
||||
|
||||
@@ -674,27 +680,27 @@ test("Admins can set group read key and then use it to create private transactio
|
||||
editable.set(reader1.id, "reader", "trusting");
|
||||
expect(editable.get(reader1.id)).toEqual("reader");
|
||||
|
||||
const revelation1 = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation1 = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation1, "trusting");
|
||||
|
||||
const revelation2 = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
reader1.currentSealerID(),
|
||||
{
|
||||
const revelation2 = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: reader1.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${reader1.id}`, revelation2, "trusting");
|
||||
|
||||
@@ -727,15 +733,15 @@ test("Admins can set group read key and then use it to create private transactio
|
||||
expect(childContentAsReader1.get("foo")).toEqual("bar");
|
||||
|
||||
groupContent.edit((editable) => {
|
||||
const revelation3 = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
reader2.currentSealerID(),
|
||||
{
|
||||
const revelation3 = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: reader2.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${reader2.id}`, revelation3, "trusting");
|
||||
});
|
||||
@@ -796,15 +802,15 @@ test("Admins can set group read key, make a private transaction in an owned obje
|
||||
|
||||
groupContent.edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
|
||||
@@ -833,15 +839,15 @@ test("Admins can set group read key, make a private transaction in an owned obje
|
||||
groupContent.edit((editable) => {
|
||||
const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret();
|
||||
|
||||
const revelation = seal(
|
||||
readKey2,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey2,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID2}_for_${admin.id}`, revelation, "trusting");
|
||||
|
||||
@@ -903,15 +909,15 @@ test("Admins can set group read key, make a private transaction in an owned obje
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
|
||||
groupContent.edit((editable) => {
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
|
||||
@@ -935,27 +941,27 @@ test("Admins can set group read key, make a private transaction in an owned obje
|
||||
const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret();
|
||||
|
||||
groupContent.edit((editable) => {
|
||||
const revelation2 = seal(
|
||||
readKey2,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation2 = seal({
|
||||
message: readKey2,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID2}_for_${admin.id}`, revelation2, "trusting");
|
||||
|
||||
const revelation3 = seal(
|
||||
readKey2,
|
||||
admin.currentSealerSecret(),
|
||||
reader.currentSealerID(),
|
||||
{
|
||||
const revelation3 = seal({
|
||||
message: readKey2,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: reader.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID2}_for_${reader.id}`, revelation3, "trusting");
|
||||
|
||||
@@ -1051,39 +1057,39 @@ test("Admins can set group read rey, make a private transaction in an owned obje
|
||||
const reader2 = node.createAccount("reader2");
|
||||
|
||||
groupContent.edit((editable) => {
|
||||
const revelation1 = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation1 = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation1, "trusting");
|
||||
|
||||
const revelation2 = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
reader.currentSealerID(),
|
||||
{
|
||||
const revelation2 = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: reader.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${reader.id}`, revelation2, "trusting");
|
||||
|
||||
const revelation3 = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
reader2.currentSealerID(),
|
||||
{
|
||||
const revelation3 = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: reader2.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${reader2.id}`, revelation3, "trusting");
|
||||
|
||||
@@ -1128,15 +1134,15 @@ test("Admins can set group read rey, make a private transaction in an owned obje
|
||||
const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret();
|
||||
|
||||
groupContent.edit((editable) => {
|
||||
const newRevelation1 = seal(
|
||||
readKey2,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const newRevelation1 = seal({
|
||||
message: readKey2,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(
|
||||
`${readKeyID2}_for_${admin.id}`,
|
||||
@@ -1144,15 +1150,15 @@ test("Admins can set group read rey, make a private transaction in an owned obje
|
||||
"trusting"
|
||||
);
|
||||
|
||||
const newRevelation2 = seal(
|
||||
readKey2,
|
||||
admin.currentSealerSecret(),
|
||||
reader2.currentSealerID(),
|
||||
{
|
||||
const newRevelation2 = seal({
|
||||
message: readKey2,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: reader2.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(
|
||||
`${readKeyID2}_for_${reader2.id}`,
|
||||
@@ -1281,15 +1287,15 @@ test("Admins can create an adminInvite, which can add an admin", () => {
|
||||
|
||||
expectGroupContent(group.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
@@ -1298,15 +1304,15 @@ test("Admins can create an adminInvite, which can add an admin", () => {
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("adminInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
const revelationForInvite = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
@@ -1332,15 +1338,15 @@ test("Admins can create an adminInvite, which can add an admin", () => {
|
||||
|
||||
expect(readKey.secret).toBeDefined();
|
||||
|
||||
const revelation = seal(
|
||||
readKey.secret!,
|
||||
getAgentSealerSecret(invitedAdminSecret),
|
||||
getAgentSealerID(invitedAdminID),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey.secret!,
|
||||
from: getAgentSealerSecret(invitedAdminSecret),
|
||||
to: getAgentSealerID(invitedAdminID),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${invitedAdminID}`,
|
||||
@@ -1377,14 +1383,18 @@ test("Admins can create an adminInvite, which can add an admin (high-level)", as
|
||||
nodeAsInvitedAdmin
|
||||
);
|
||||
|
||||
expect(groupAsInvitedAdmin.underlyingMap.get(invitedAdminID)).toEqual("admin");
|
||||
expect(groupAsInvitedAdmin.underlyingMap.get(invitedAdminID)).toEqual(
|
||||
"admin"
|
||||
);
|
||||
expect(
|
||||
groupAsInvitedAdmin.underlyingMap.core.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
|
||||
groupAsInvitedAdmin.addMemberInternal(thirdAdminID, "admin");
|
||||
|
||||
expect(groupAsInvitedAdmin.underlyingMap.get(thirdAdminID)).toEqual("admin");
|
||||
expect(groupAsInvitedAdmin.underlyingMap.get(thirdAdminID)).toEqual(
|
||||
"admin"
|
||||
);
|
||||
});
|
||||
|
||||
test("Admins can create a writerInvite, which can add a writer", () => {
|
||||
@@ -1395,15 +1405,15 @@ test("Admins can create a writerInvite, which can add a writer", () => {
|
||||
|
||||
expectGroupContent(group.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
@@ -1412,15 +1422,15 @@ test("Admins can create a writerInvite, which can add a writer", () => {
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("writerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
const revelationForInvite = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
@@ -1446,15 +1456,15 @@ test("Admins can create a writerInvite, which can add a writer", () => {
|
||||
|
||||
expect(readKey.secret).toBeDefined();
|
||||
|
||||
const revelation = seal(
|
||||
readKey.secret!,
|
||||
getAgentSealerSecret(invitedWriterSecret),
|
||||
getAgentSealerID(invitedWriterID),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey.secret!,
|
||||
from: getAgentSealerSecret(invitedWriterSecret),
|
||||
to: getAgentSealerID(invitedWriterID),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${invitedWriterID}`,
|
||||
@@ -1488,13 +1498,14 @@ test("Admins can create a writerInvite, which can add a writer (high-level)", as
|
||||
nodeAsInvitedWriter
|
||||
);
|
||||
|
||||
expect(groupAsInvitedWriter.underlyingMap.get(invitedWriterID)).toEqual("writer");
|
||||
expect(groupAsInvitedWriter.underlyingMap.get(invitedWriterID)).toEqual(
|
||||
"writer"
|
||||
);
|
||||
expect(
|
||||
groupAsInvitedWriter.underlyingMap.core.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
test("Admins can create a readerInvite, which can add a reader", () => {
|
||||
const { node, group, admin } = newGroup();
|
||||
|
||||
@@ -1503,15 +1514,15 @@ test("Admins can create a readerInvite, which can add a reader", () => {
|
||||
|
||||
expectGroupContent(group.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
@@ -1520,15 +1531,15 @@ test("Admins can create a readerInvite, which can add a reader", () => {
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("readerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
const revelationForInvite = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
@@ -1554,15 +1565,15 @@ test("Admins can create a readerInvite, which can add a reader", () => {
|
||||
|
||||
expect(readKey.secret).toBeDefined();
|
||||
|
||||
const revelation = seal(
|
||||
readKey.secret!,
|
||||
getAgentSealerSecret(invitedReaderSecret),
|
||||
getAgentSealerID(invitedReaderID),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey.secret!,
|
||||
from: getAgentSealerSecret(invitedReaderSecret),
|
||||
to: getAgentSealerID(invitedReaderID),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${invitedReaderID}`,
|
||||
@@ -1596,7 +1607,9 @@ test("Admins can create a readerInvite, which can add a reader (high-level)", as
|
||||
nodeAsInvitedReader
|
||||
);
|
||||
|
||||
expect(groupAsInvitedReader.underlyingMap.get(invitedReaderID)).toEqual("reader");
|
||||
expect(groupAsInvitedReader.underlyingMap.get(invitedReaderID)).toEqual(
|
||||
"reader"
|
||||
);
|
||||
expect(
|
||||
groupAsInvitedReader.underlyingMap.core.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
@@ -1610,15 +1623,15 @@ test("WriterInvites can not invite admins", () => {
|
||||
|
||||
expectGroupContent(group.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
@@ -1627,15 +1640,15 @@ test("WriterInvites can not invite admins", () => {
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("writerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
const revelationForInvite = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
@@ -1666,15 +1679,15 @@ test("ReaderInvites can not invite admins", () => {
|
||||
|
||||
expectGroupContent(group.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
@@ -1683,15 +1696,15 @@ test("ReaderInvites can not invite admins", () => {
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("readerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
const revelationForInvite = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
@@ -1722,15 +1735,15 @@ test("ReaderInvites can not invite writers", () => {
|
||||
|
||||
expectGroupContent(group.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
const revelation = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID(),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
@@ -1739,15 +1752,15 @@ test("ReaderInvites can not invite writers", () => {
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("readerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
const revelationForInvite = seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: group.id,
|
||||
tx: group.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
318
packages/cojson/src/tests/queries.test.ts
Normal file
318
packages/cojson/src/tests/queries.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { BinaryCoStream, CoList, CoMap, CoStream, Group, LocalNode, cojsonReady } from "..";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
});
|
||||
|
||||
test("Queries with maps work", async () => {
|
||||
const { node, accountID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
let map = group.createMap<
|
||||
CoMap<{
|
||||
hello: "world";
|
||||
subMap: CoMap<{
|
||||
hello: "world" | "moon" | "sun";
|
||||
id: string;
|
||||
}>;
|
||||
}>
|
||||
>();
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const unsubQuery = node.query(map.id, (queriedMap) => {
|
||||
// console.log("update", update);
|
||||
if (queriedMap) {
|
||||
expect(queriedMap.type).toBe("comap");
|
||||
expect(queriedMap.id).toEqual(map.id);
|
||||
expect(queriedMap.core).toEqual(map.core);
|
||||
expect(queriedMap.group).toBeInstanceOf(Group);
|
||||
expect(queriedMap.group.id).toBe(group.id);
|
||||
expect(queriedMap.meta).toBe(null);
|
||||
expect(queriedMap.hello).toBe("world");
|
||||
expect(Object.keys(queriedMap)).toEqual(["hello", "subMap"]);
|
||||
if (queriedMap.edits.hello?.by?.profile?.name) {
|
||||
expect(queriedMap.edits.hello).toMatchObject({
|
||||
by: {
|
||||
id: accountID,
|
||||
profile: {
|
||||
id: node.expectProfileLoaded(accountID).id,
|
||||
name: "Hermes Puggington",
|
||||
},
|
||||
isMe: true,
|
||||
},
|
||||
tx: map.lastEditAt("hello")!.tx,
|
||||
at: new Date(map.lastEditAt("hello")!.at),
|
||||
});
|
||||
if (queriedMap.subMap) {
|
||||
expect(queriedMap.subMap.type).toBe("comap");
|
||||
expect(queriedMap.subMap.id).toEqual("foreignID");
|
||||
expect(queriedMap.subMap.core).toEqual(subMap.core);
|
||||
expect(queriedMap.subMap.group).toBeInstanceOf(Group);
|
||||
expect(queriedMap.subMap.group.id).toBe(group.id);
|
||||
expect(queriedMap.subMap.meta).toBe(null);
|
||||
if (queriedMap.subMap.hello === "moon") {
|
||||
// console.log("got to 'moon'");
|
||||
queriedMap.subMap.set("hello", "sun");
|
||||
} else if (
|
||||
queriedMap.subMap.hello === "sun" &&
|
||||
queriedMap.subMap.edits.hello?.by?.profile?.name ===
|
||||
"Hermes Puggington"
|
||||
) {
|
||||
// console.log("final update", queriedMap);
|
||||
resolve();
|
||||
unsubQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
map = map.set("hello", "world");
|
||||
|
||||
let subMap = group.createMap<
|
||||
CoMap<{
|
||||
hello: "world" | "moon" | "sun";
|
||||
id: string;
|
||||
}>
|
||||
>();
|
||||
|
||||
map = map.set("subMap", subMap);
|
||||
|
||||
subMap = subMap.mutate((subMap) => {
|
||||
subMap.set("hello", "world");
|
||||
subMap.set("id", "foreignID");
|
||||
});
|
||||
|
||||
subMap = subMap.set("hello", "moon");
|
||||
|
||||
await done;
|
||||
});
|
||||
|
||||
test("Queries with lists work", () => {
|
||||
const { node, accountID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
let list = group.createList<CoList<string>>();
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const unsubQuery = node.query(list.id, (queriedList) => {
|
||||
if (queriedList) {
|
||||
// console.log("update", queriedList, queriedList.edits);
|
||||
expect(queriedList.type).toBe("colist");
|
||||
expect(queriedList.id).toEqual(list.id);
|
||||
expect(queriedList.core).toEqual(list.core);
|
||||
expect(queriedList.group).toBeInstanceOf(Group);
|
||||
expect(queriedList.group.id).toBe(group.id);
|
||||
expect(queriedList.meta).toBe(null);
|
||||
expect(queriedList[0]).toBe("hello");
|
||||
expect(queriedList[1]).toBe("world");
|
||||
expect(queriedList[2]).toBe("moon");
|
||||
if (queriedList.edits[2]?.by?.profile?.name) {
|
||||
expect(queriedList.edits[2]).toMatchObject({
|
||||
by: {
|
||||
id: accountID,
|
||||
profile: {
|
||||
id: node.expectProfileLoaded(accountID).id,
|
||||
name: "Hermes Puggington",
|
||||
},
|
||||
isMe: true,
|
||||
},
|
||||
at: expect.any(Date),
|
||||
});
|
||||
if (queriedList.length === 3) {
|
||||
queriedList.append("sun");
|
||||
|
||||
} else if (
|
||||
queriedList.length === 4 &&
|
||||
queriedList.edits[3]?.by?.profile?.name ===
|
||||
"Hermes Puggington"
|
||||
) {
|
||||
expect(queriedList[3]).toBe("sun");
|
||||
// console.log("final update", queriedList);
|
||||
resolve();
|
||||
unsubQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
list = list.mutate((list) => {
|
||||
list.append("hello");
|
||||
list.append("world");
|
||||
list.append("moon");
|
||||
});
|
||||
|
||||
return done;
|
||||
});
|
||||
|
||||
test("List of nested maps works", () => {
|
||||
const { node } = LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
let list = group.createList<CoList<CoMap<{ hello: "world" }>>>();
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const unsubQuery = node.query(list.id, (queriedList) => {
|
||||
if (queriedList && queriedList[0]) {
|
||||
// console.log("update", queriedList);
|
||||
expect(queriedList[0]).toMatchObject({
|
||||
hello: "world",
|
||||
id: list.get(0)!,
|
||||
});
|
||||
// console.log("final update", queriedList);
|
||||
resolve();
|
||||
unsubQuery();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
list = list.append(
|
||||
group.createMap<CoMap<{ hello: "world" }>>({
|
||||
hello: "world",
|
||||
})
|
||||
);
|
||||
|
||||
return done;
|
||||
});
|
||||
|
||||
test("Can call .map on a quieried coList", async () => {
|
||||
const { node } = LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
let list = group.createList<CoList<string>>();
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
|
||||
const unsubQuery = node.query(list.id, (queriedList) => {
|
||||
|
||||
if (queriedList && queriedList[0]) {
|
||||
// console.log("update", queriedList);
|
||||
expect(queriedList.map((item) => item + "!!!")).toEqual([
|
||||
"hello!!!",
|
||||
"world!!!",
|
||||
]);
|
||||
// console.log("final update", queriedList);
|
||||
resolve();
|
||||
unsubQuery();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
list = list.mutate((list) => {
|
||||
list.append("hello");
|
||||
list.append("world");
|
||||
});
|
||||
|
||||
await done;
|
||||
});
|
||||
|
||||
test("Queries with streams work", () => {
|
||||
const { node, accountID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
let stream = group.createStream<CoStream<string>>();
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const unsubQuery = node.query(stream.id, (queriedStream) => {
|
||||
if (queriedStream) {
|
||||
// console.log("update", queriedStream);
|
||||
if (queriedStream.me?.by?.profile?.name) {
|
||||
expect(queriedStream.type).toBe("costream");
|
||||
expect(queriedStream.id).toEqual(stream.id);
|
||||
expect(queriedStream.core).toEqual(stream.core);
|
||||
expect(queriedStream.group).toBeInstanceOf(Group);
|
||||
expect(queriedStream.group.id).toBe(group.id);
|
||||
expect(queriedStream.meta).toBe(null);
|
||||
const expectedEntry = {
|
||||
last: "world",
|
||||
by: {
|
||||
id: accountID,
|
||||
isMe: true,
|
||||
profile: {
|
||||
id: node.expectProfileLoaded(accountID).id,
|
||||
name: "Hermes Puggington",
|
||||
},
|
||||
},
|
||||
at: new Date(
|
||||
stream.items[node.currentSessionID][1].madeAt
|
||||
),
|
||||
all: [
|
||||
{
|
||||
value: "hello",
|
||||
at: new Date(
|
||||
stream.items[
|
||||
node.currentSessionID
|
||||
][0].madeAt
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "world",
|
||||
at: new Date(
|
||||
stream.items[
|
||||
node.currentSessionID
|
||||
][1].madeAt
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(queriedStream.perSession).toMatchObject({
|
||||
[node.currentSessionID]: expectedEntry,
|
||||
});
|
||||
expect(queriedStream.perAccount).toMatchObject({
|
||||
[accountID]: expectedEntry,
|
||||
});
|
||||
expect(queriedStream.me).toMatchObject(expectedEntry);
|
||||
// console.log("final update", queriedStream);
|
||||
resolve();
|
||||
unsubQuery();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
stream = stream.push("hello");
|
||||
stream = stream.push("world");
|
||||
|
||||
return done;
|
||||
});
|
||||
|
||||
test("Streams of nested maps work", () => {
|
||||
const { node } = LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
let stream = group.createStream<CoStream<CoMap<{ hello: "world" }>>>();
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const unsubQuery = node.query(stream.id, (queriedStream) => {
|
||||
if (queriedStream && queriedStream.me?.last) {
|
||||
// console.log("update", queriedList);
|
||||
expect(queriedStream.me.last).toMatchObject({
|
||||
hello: "world",
|
||||
id: map.id,
|
||||
});
|
||||
// console.log("final update", queriedList);
|
||||
resolve();
|
||||
unsubQuery();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const map = group.createMap<CoMap<{ hello: "world" }>>({
|
||||
hello: "world",
|
||||
});
|
||||
|
||||
stream = stream.push(map);
|
||||
|
||||
return done;
|
||||
});
|
||||
@@ -1,19 +1,17 @@
|
||||
import { newRandomSessionID } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { SyncMessage } from "./sync.js";
|
||||
import { expectMap } from "./coValue.js";
|
||||
import { MapOpPayload } from "./coValues/coMap.js";
|
||||
import { Group } from "./group.js";
|
||||
import { newRandomSessionID } from "../coValueCore.js";
|
||||
import { LocalNode } from "../localNode.js";
|
||||
import { SyncMessage } from "../sync.js";
|
||||
import { expectMap } from "../coValue.js";
|
||||
import { MapOpPayload } from "../coValues/coMap.js";
|
||||
import { Group } from "../group.js";
|
||||
import {
|
||||
randomAnonymousAccountAndSessionID,
|
||||
shouldNotResolve,
|
||||
} from "./testUtils.js";
|
||||
import {
|
||||
connectedPeers,
|
||||
newStreamPair
|
||||
} from "./streamUtils.js";
|
||||
import { AccountID } from "./account.js";
|
||||
import { cojsonReady } from "./index.js";
|
||||
import { connectedPeers, newStreamPair } from "../streamUtils.js";
|
||||
import { AccountID } from "../account.js";
|
||||
import { cojsonReady } from "../index.js";
|
||||
import { stableStringify } from "../jsonStringify.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
@@ -34,7 +32,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -84,13 +82,13 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
],
|
||||
]),
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
@@ -116,7 +114,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -162,13 +160,13 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
],
|
||||
]),
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
@@ -193,7 +191,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -251,13 +249,13 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
],
|
||||
]),
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
@@ -283,13 +281,13 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
],
|
||||
]),
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
@@ -314,7 +312,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -362,13 +360,13 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
],
|
||||
]),
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
@@ -389,7 +387,7 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -438,8 +436,9 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
editable.set("goodbye", "world", "trusting");
|
||||
});
|
||||
|
||||
const _mapEditMsg1 = await reader.read();
|
||||
const _mapEditMsg2 = await reader.read();
|
||||
const _mapEditMsgs = await reader.read();
|
||||
|
||||
console.log("Sending correction");
|
||||
|
||||
await writer.write({
|
||||
action: "known",
|
||||
@@ -465,13 +464,13 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
],
|
||||
]),
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
@@ -492,7 +491,7 @@ test("If we add a peer, but it never subscribes to a coValue, it won't get any m
|
||||
const [inRx, _inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -521,7 +520,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
const [inRx, _inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -568,13 +567,13 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
changes: stableStringify([
|
||||
{
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
],
|
||||
]),
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
@@ -593,7 +592,7 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
|
||||
const [inRx, _inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -647,7 +646,7 @@ test("When we connect a new server peer, we try to sync all existing coValues to
|
||||
const [inRx, _inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -683,7 +682,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -724,7 +723,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
const [inRx1, inTx1] = newStreamPair<SyncMessage>();
|
||||
const [outRx1, outTx1] = newStreamPair<SyncMessage>();
|
||||
|
||||
node1.sync.addPeer({
|
||||
node1.syncManager.addPeer({
|
||||
id: "test2",
|
||||
incoming: inRx1,
|
||||
outgoing: outTx1,
|
||||
@@ -739,7 +738,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
const [inRx2, inTx2] = newStreamPair<SyncMessage>();
|
||||
const [outRx2, outTx2] = newStreamPair<SyncMessage>();
|
||||
|
||||
node2.sync.addPeer({
|
||||
node2.syncManager.addPeer({
|
||||
id: "test1",
|
||||
incoming: inRx2,
|
||||
outgoing: outTx2,
|
||||
@@ -770,7 +769,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
expect(groupTellKnownStateMsg.value).toMatchObject(groupStateEx(group));
|
||||
|
||||
expect(
|
||||
node2.sync.peers["test1"]!.optimisticKnownStates[
|
||||
node2.syncManager.peers["test1"]!.optimisticKnownStates[
|
||||
group.underlyingMap.core.id
|
||||
]
|
||||
).toBeDefined();
|
||||
@@ -851,8 +850,8 @@ test.skip("When loading a coValue on one node, the server node it is requested f
|
||||
|
||||
const [node1asPeer, node2asPeer] = connectedPeers("peer1", "peer2");
|
||||
|
||||
node1.sync.addPeer(node2asPeer);
|
||||
node2.sync.addPeer(node1asPeer);
|
||||
node1.syncManager.addPeer(node2asPeer);
|
||||
node2.syncManager.addPeer(node1asPeer);
|
||||
|
||||
await node2.loadCoValue(map.core.id);
|
||||
|
||||
@@ -884,8 +883,8 @@ test("Can sync a coValue through a server to another client", async () => {
|
||||
peer2role: "client",
|
||||
});
|
||||
|
||||
client1.sync.addPeer(serverAsPeer);
|
||||
server.sync.addPeer(client1AsPeer);
|
||||
client1.syncManager.addPeer(serverAsPeer);
|
||||
server.syncManager.addPeer(client1AsPeer);
|
||||
|
||||
const client2 = new LocalNode(admin, newRandomSessionID(admin.id));
|
||||
|
||||
@@ -895,8 +894,8 @@ test("Can sync a coValue through a server to another client", async () => {
|
||||
{ peer1role: "server", peer2role: "client" }
|
||||
);
|
||||
|
||||
client2.sync.addPeer(serverAsOtherPeer);
|
||||
server.sync.addPeer(client2AsPeer);
|
||||
client2.syncManager.addPeer(serverAsOtherPeer);
|
||||
server.syncManager.addPeer(client2AsPeer);
|
||||
|
||||
const mapOnClient2 = await client2.loadCoValue(map.core.id);
|
||||
|
||||
@@ -927,8 +926,8 @@ test("Can sync a coValue with private transactions through a server to another c
|
||||
peer2role: "client",
|
||||
});
|
||||
|
||||
client1.sync.addPeer(serverAsPeer);
|
||||
server.sync.addPeer(client1AsPeer);
|
||||
client1.syncManager.addPeer(serverAsPeer);
|
||||
server.syncManager.addPeer(client1AsPeer);
|
||||
|
||||
const client2 = new LocalNode(admin, newRandomSessionID(admin.id));
|
||||
|
||||
@@ -938,8 +937,8 @@ test("Can sync a coValue with private transactions through a server to another c
|
||||
{ trace: true, peer1role: "server", peer2role: "client" }
|
||||
);
|
||||
|
||||
client2.sync.addPeer(serverAsOtherPeer);
|
||||
server.sync.addPeer(client2AsPeer);
|
||||
client2.syncManager.addPeer(serverAsOtherPeer);
|
||||
server.syncManager.addPeer(client2AsPeer);
|
||||
|
||||
const mapOnClient2 = await client2.loadCoValue(map.core.id);
|
||||
|
||||
@@ -957,7 +956,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -999,7 +998,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(node.sync.peers["test"]).toBeUndefined();
|
||||
expect(node.syncManager.peers["test"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("When a peer's outgoing/writable stream closes, we remove the peer", async () => {
|
||||
@@ -1011,7 +1010,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
|
||||
node.sync.addPeer({
|
||||
node.syncManager.addPeer({
|
||||
id: "test",
|
||||
incoming: inRx,
|
||||
outgoing: outTx,
|
||||
@@ -1058,7 +1057,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(node.sync.peers["test"]).toBeUndefined();
|
||||
expect(node.syncManager.peers["test"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("If we start loading a coValue before connecting to a peer that has it, it will load it once we connect", async () => {
|
||||
@@ -1081,13 +1080,13 @@ test("If we start loading a coValue before connecting to a peer that has it, it
|
||||
trace: true,
|
||||
});
|
||||
|
||||
node1.sync.addPeer(node2asPeer);
|
||||
node1.syncManager.addPeer(node2asPeer);
|
||||
|
||||
const mapOnNode2Promise = node2.loadCoValue(map.core.id);
|
||||
|
||||
expect(node2.coValues[map.core.id]?.state).toEqual("loading");
|
||||
|
||||
node2.sync.addPeer(node1asPeer);
|
||||
node2.syncManager.addPeer(node1asPeer);
|
||||
|
||||
const mapOnNode2 = await mapOnNode2Promise;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "./crypto.js";
|
||||
import { newRandomSessionID } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { expectGroupContent } from "./group.js";
|
||||
import { AnonymousControlledAccount } from "./account.js";
|
||||
import { SessionID } from "./ids.js";
|
||||
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "../crypto.js";
|
||||
import { newRandomSessionID } from "../coValueCore.js";
|
||||
import { LocalNode } from "../localNode.js";
|
||||
import { expectGroupContent } from "../group.js";
|
||||
import { AnonymousControlledAccount } from "../account.js";
|
||||
import { SessionID } from "../ids.js";
|
||||
// @ts-ignore
|
||||
import { expect } from "bun:test";
|
||||
|
||||
export function randomAnonymousAccountAndSessionID(): [AnonymousControlledAccount, SessionID] {
|
||||
const agentSecret = newRandomAgentSecret();
|
||||
@@ -27,7 +29,7 @@ export function newGroup() {
|
||||
|
||||
const groupContent = expectGroupContent(group.getCurrentContent());
|
||||
|
||||
groupContent.edit((editable) => {
|
||||
groupContent.mutate((editable) => {
|
||||
editable.set(admin.id, "admin", "trusting");
|
||||
expect(editable.get(admin.id)).toEqual("admin");
|
||||
});
|
||||
@@ -42,7 +44,7 @@ export function groupWithTwoAdmins() {
|
||||
|
||||
let content = expectGroupContent(group.getCurrentContent());
|
||||
|
||||
content.edit((editable) => {
|
||||
content.mutate((editable) => {
|
||||
editable.set(otherAdmin.id, "admin", "trusting");
|
||||
expect(editable.get(otherAdmin.id)).toEqual("admin");
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-browser-auth-local",
|
||||
"version": "0.1.12",
|
||||
"version": "0.3.4",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser": "^0.1.12",
|
||||
"jazz-browser": "^0.3.4",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -72,7 +72,7 @@ export class BrowserLocalAuth implements AuthProvider {
|
||||
this.appHostname
|
||||
);
|
||||
for (const peer of initialPeers) {
|
||||
node.sync.addPeer(peer);
|
||||
node.syncManager.addPeer(peer);
|
||||
}
|
||||
doneSigningUpOrLoggingIn(node);
|
||||
this.driver.onSignedIn({ logOut });
|
||||
@@ -128,7 +128,7 @@ async function signUp(
|
||||
},
|
||||
user: {
|
||||
id: webAuthNCredentialPayload,
|
||||
name: username + `(${new Date().toLocaleString()})`,
|
||||
name: username + ` (${new Date().toLocaleString()})`,
|
||||
displayName: username,
|
||||
},
|
||||
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
|
||||
|
||||
17
packages/jazz-browser-media-images/.eslintrc.cjs
Normal file
17
packages/jazz-browser-media-images/.eslintrc.cjs
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
};
|
||||
171
packages/jazz-browser-media-images/.gitignore
vendored
Normal file
171
packages/jazz-browser-media-images/.gitignore
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
.DS_Store
|
||||
2
packages/jazz-browser-media-images/.npmignore
Normal file
2
packages/jazz-browser-media-images/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
coverage
|
||||
node_modules
|
||||
21
packages/jazz-browser-media-images/package.json
Normal file
21
packages/jazz-browser-media-images/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "jazz-browser-media-images",
|
||||
"version": "0.3.4",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.3.4",
|
||||
"image-blob-reduce": "^4.1.0",
|
||||
"jazz-browser": "^0.3.4",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/image-blob-reduce": "^4.1.1"
|
||||
}
|
||||
}
|
||||
334
packages/jazz-browser-media-images/src/index.ts
Normal file
334
packages/jazz-browser-media-images/src/index.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { CoID, Group, LocalNode, Media } from "cojson";
|
||||
|
||||
import ImageBlobReduce from "image-blob-reduce";
|
||||
import Pica from "pica";
|
||||
import {
|
||||
createBinaryStreamFromBlob,
|
||||
readBlobFromBinaryStream,
|
||||
} from "jazz-browser";
|
||||
|
||||
const pica = new Pica();
|
||||
|
||||
export async function createImage(
|
||||
imageBlobOrFile: Blob | File,
|
||||
inGroup: Group
|
||||
): Promise<Media.ImageDefinition> {
|
||||
let originalWidth!: number;
|
||||
let originalHeight!: number;
|
||||
const Reducer = new ImageBlobReduce({ pica });
|
||||
Reducer.after("_blob_to_image", (env) => {
|
||||
originalWidth =
|
||||
(env as unknown as { orientation: number }).orientation & 4
|
||||
? env.image.height
|
||||
: env.image.width;
|
||||
originalHeight =
|
||||
(env as unknown as { orientation: number }).orientation & 4
|
||||
? env.image.width
|
||||
: env.image.height;
|
||||
return Promise.resolve(env);
|
||||
});
|
||||
|
||||
const placeholderDataURL = (
|
||||
await Reducer.toCanvas(imageBlobOrFile, { max: 8 })
|
||||
).toDataURL("image/png");
|
||||
|
||||
let imageDefinition = inGroup.createMap<Media.ImageDefinition>();
|
||||
|
||||
imageDefinition = imageDefinition.edit((imageDefinition) => {
|
||||
imageDefinition.set("originalSize", [originalWidth, originalHeight]);
|
||||
imageDefinition.set("placeholderDataURL", placeholderDataURL);
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
const max256 = await Reducer.toBlob(imageBlobOrFile, { max: 256 });
|
||||
|
||||
if (originalWidth > 256 || originalHeight > 256) {
|
||||
const width =
|
||||
originalWidth > originalHeight
|
||||
? 256
|
||||
: Math.round(256 * (originalWidth / originalHeight));
|
||||
const height =
|
||||
originalHeight > originalWidth
|
||||
? 256
|
||||
: Math.round(256 * (originalHeight / originalWidth));
|
||||
|
||||
const binaryStreamId = (
|
||||
await createBinaryStreamFromBlob(max256, inGroup)
|
||||
).id;
|
||||
|
||||
imageDefinition.edit((imageDefinition) => {
|
||||
imageDefinition.set(`${width}x${height}`, binaryStreamId);
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const max1024 = await Reducer.toBlob(imageBlobOrFile, { max: 1024 });
|
||||
|
||||
if (originalWidth > 1024 || originalHeight > 1024) {
|
||||
const width =
|
||||
originalWidth > originalHeight
|
||||
? 1024
|
||||
: Math.round(1024 * (originalWidth / originalHeight));
|
||||
const height =
|
||||
originalHeight > originalWidth
|
||||
? 1024
|
||||
: Math.round(1024 * (originalHeight / originalWidth));
|
||||
|
||||
const binaryStreamId = (
|
||||
await createBinaryStreamFromBlob(max1024, inGroup)
|
||||
).id;
|
||||
|
||||
imageDefinition.edit((imageDefinition) => {
|
||||
imageDefinition.set(`${width}x${height}`, binaryStreamId);
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const max2048 = await Reducer.toBlob(imageBlobOrFile, { max: 2048 });
|
||||
|
||||
if (originalWidth > 2048 || originalHeight > 2048) {
|
||||
const width =
|
||||
originalWidth > originalHeight
|
||||
? 2048
|
||||
: Math.round(2048 * (originalWidth / originalHeight));
|
||||
const height =
|
||||
originalHeight > originalWidth
|
||||
? 2048
|
||||
: Math.round(2048 * (originalHeight / originalWidth));
|
||||
|
||||
const binaryStreamId = (
|
||||
await createBinaryStreamFromBlob(max2048, inGroup)
|
||||
).id;
|
||||
|
||||
imageDefinition.edit((imageDefinition) => {
|
||||
imageDefinition.set(`${width}x${height}`, binaryStreamId);
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const originalBinaryStreamId = (
|
||||
await createBinaryStreamFromBlob(imageBlobOrFile, inGroup)
|
||||
).id;
|
||||
|
||||
imageDefinition.edit((imageDefinition) => {
|
||||
imageDefinition.set(
|
||||
`${originalWidth}x${originalHeight}`,
|
||||
originalBinaryStreamId
|
||||
);
|
||||
});
|
||||
}, 0);
|
||||
|
||||
return imageDefinition;
|
||||
}
|
||||
|
||||
export type LoadingImageInfo = {
|
||||
originalSize?: [number, number];
|
||||
placeholderDataURL?: string;
|
||||
highestResSrc?: string;
|
||||
};
|
||||
|
||||
export function loadImage(
|
||||
imageDef: CoID<Media.ImageDefinition> | Media.ImageDefinition | {id: CoID<Media.ImageDefinition>},
|
||||
localNode: LocalNode,
|
||||
progressiveCallback: (update: LoadingImageInfo) => void
|
||||
): () => void {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let stopped = false;
|
||||
|
||||
const resState: {
|
||||
[res: `${number}x${number}`]:
|
||||
| { state: "queued" }
|
||||
| { state: "waiting" }
|
||||
| { state: "loading"; doneOrFailed: Promise<void> }
|
||||
| { state: "loaded"; blobURL: string }
|
||||
| { state: "revoked" }
|
||||
| { state: "failed" }
|
||||
| undefined;
|
||||
} = {};
|
||||
|
||||
const cleanUp = () => {
|
||||
stopped = true;
|
||||
for (const [res, entry] of Object.entries(resState)) {
|
||||
if (entry?.state === "loaded") {
|
||||
resState[res as `${number}x${number}`] = { state: "revoked" };
|
||||
// prevent flashing from immediate revocation
|
||||
setTimeout(() => {URL.revokeObjectURL(entry.blobURL)}, 3000);
|
||||
}
|
||||
}
|
||||
unsubscribe?.();
|
||||
};
|
||||
|
||||
localNode
|
||||
.load(typeof imageDef === "string" ? imageDef : imageDef.id)
|
||||
.then((imageDefinition) => {
|
||||
if (stopped) return;
|
||||
unsubscribe = imageDefinition.subscribe(async (imageDefinition) => {
|
||||
if (stopped) return;
|
||||
|
||||
const originalSize = imageDefinition.get("originalSize");
|
||||
const placeholderDataURL =
|
||||
imageDefinition.get("placeholderDataURL");
|
||||
|
||||
const resolutions = imageDefinition
|
||||
.keys()
|
||||
.filter(
|
||||
(key): key is `${number}x${number}` =>
|
||||
!!key.match(/\d+x\d+/)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const widthA = Number(a.split("x")[0]);
|
||||
const widthB = Number(b.split("x")[0]);
|
||||
return widthA - widthB;
|
||||
});
|
||||
|
||||
const startLoading = async () => {
|
||||
const notYetQueuedOrLoading = resolutions.filter(
|
||||
(res) => !resState[res]
|
||||
);
|
||||
|
||||
// console.log(
|
||||
// "Loading iteration",
|
||||
// resolutions,
|
||||
// resState,
|
||||
// notYetQueuedOrLoading
|
||||
// );
|
||||
|
||||
for (const res of notYetQueuedOrLoading) {
|
||||
resState[res] = { state: "queued" };
|
||||
}
|
||||
|
||||
for (const res of notYetQueuedOrLoading) {
|
||||
if (stopped) return;
|
||||
resState[res] = { state: "waiting" };
|
||||
|
||||
const binaryStreamId = imageDefinition.get(res)!;
|
||||
// console.log(
|
||||
// "Loading image res",
|
||||
// imageID,
|
||||
// res,
|
||||
// binaryStreamId
|
||||
// );
|
||||
|
||||
const binaryStream = await localNode.load(
|
||||
binaryStreamId
|
||||
);
|
||||
|
||||
if (stopped) return;
|
||||
if (!binaryStream) {
|
||||
resState[res] = { state: "failed" };
|
||||
console.error(
|
||||
"Loading image res failed",
|
||||
imageDef,
|
||||
res,
|
||||
binaryStreamId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolveFullyLoaded) => {
|
||||
const unsubFromStream = binaryStream.subscribe(
|
||||
async (_) => {
|
||||
if (stopped) return;
|
||||
const currentState = resState[res];
|
||||
if (currentState?.state === "loading") {
|
||||
await currentState.doneOrFailed;
|
||||
// console.log(
|
||||
// "Retrying image res after previous attempt",
|
||||
// imageID,
|
||||
// res,
|
||||
// binaryStreamId
|
||||
// );
|
||||
}
|
||||
if (resState[res]?.state === "loaded") {
|
||||
return;
|
||||
}
|
||||
|
||||
const doneOrFailed = new Promise<void>(
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
async (resolveDoneOrFailed) => {
|
||||
const blob =
|
||||
await readBlobFromBinaryStream(
|
||||
binaryStreamId,
|
||||
localNode
|
||||
);
|
||||
|
||||
if (stopped) return;
|
||||
if (!blob) {
|
||||
// console.log(
|
||||
// "Image res not available yet",
|
||||
// imageID,
|
||||
// res,
|
||||
// binaryStreamId
|
||||
// );
|
||||
resolveDoneOrFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
const blobURL =
|
||||
URL.createObjectURL(blob);
|
||||
resState[res] = {
|
||||
state: "loaded",
|
||||
blobURL,
|
||||
};
|
||||
|
||||
// console.log(
|
||||
// "Loaded image res",
|
||||
// imageID,
|
||||
// res,
|
||||
// binaryStreamId
|
||||
// );
|
||||
|
||||
progressiveCallback({
|
||||
originalSize,
|
||||
placeholderDataURL,
|
||||
highestResSrc: blobURL,
|
||||
});
|
||||
|
||||
unsubFromStream();
|
||||
resolveDoneOrFailed();
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 0)
|
||||
);
|
||||
|
||||
resolveFullyLoaded();
|
||||
}
|
||||
);
|
||||
|
||||
resState[res] = {
|
||||
state: "loading",
|
||||
doneOrFailed,
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
!Object.values(resState).some(
|
||||
(entry) => entry?.state === "loaded"
|
||||
)
|
||||
) {
|
||||
progressiveCallback({
|
||||
originalSize,
|
||||
placeholderDataURL,
|
||||
});
|
||||
}
|
||||
|
||||
startLoading().catch((err) => {
|
||||
console.error("Error loading image", imageDef, err);
|
||||
cleanUp();
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error loading image", imageDef, err);
|
||||
cleanUp();
|
||||
});
|
||||
|
||||
return cleanUp;
|
||||
}
|
||||
16
packages/jazz-browser-media-images/tsconfig.json
Normal file
16
packages/jazz-browser-media-images/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
}
|
||||
75
packages/jazz-browser-media-images/yarn.lock
Normal file
75
packages/jazz-browser-media-images/yarn.lock
Normal file
@@ -0,0 +1,75 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@noble/ciphers@^0.1.3":
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.1.4.tgz#96327dca147829ed9eee0d96cfdf7c57915765f0"
|
||||
integrity sha512-d3ZR8vGSpy3v/nllS+bD/OMN5UZqusWiQqkyj7AwzTnhXFH72pF5oB4Ach6DQ50g5kXxC28LdaYBEpsyv9KOUQ==
|
||||
|
||||
"@noble/curves@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d"
|
||||
integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==
|
||||
dependencies:
|
||||
"@noble/hashes" "1.3.1"
|
||||
|
||||
"@noble/hashes@1.3.1", "@noble/hashes@^1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9"
|
||||
integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==
|
||||
|
||||
"@scure/base@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"
|
||||
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==
|
||||
|
||||
"@types/prop-types@*":
|
||||
version "15.7.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
|
||||
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
|
||||
|
||||
"@types/react@^18.2.19":
|
||||
version "18.2.19"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.19.tgz#f77cb2c8307368e624d464a25b9675fa35f95a8b"
|
||||
integrity sha512-e2S8wmY1ePfM517PqCG80CcE48Xs5k0pwJzuDZsfE8IZRRBfOMCF+XqnFxu6mWtyivum1MQm4aco+WIt6Coimw==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
"@types/scheduler" "*"
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/scheduler@*":
|
||||
version "0.16.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"
|
||||
integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==
|
||||
|
||||
cojson@^0.0.14:
|
||||
version "0.0.14"
|
||||
resolved "https://registry.yarnpkg.com/cojson/-/cojson-0.0.14.tgz#e7b190ade1efc20d6f0fa12411d7208cdc0f19f7"
|
||||
integrity sha512-TFenIGswEEhnZlCmq+B1NZPztjovZ72AjK1YkkZca54ZFbB1lAHdPt2hqqu/QBO24C9+6DtuoS2ixm6gbSBWCg==
|
||||
dependencies:
|
||||
"@noble/ciphers" "^0.1.3"
|
||||
"@noble/curves" "^1.1.0"
|
||||
"@noble/hashes" "^1.3.1"
|
||||
"@scure/base" "^1.1.1"
|
||||
fast-json-stable-stringify "^2.1.0"
|
||||
isomorphic-streams "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
||||
|
||||
csstype@^3.0.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
|
||||
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
|
||||
|
||||
fast-json-stable-stringify@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
|
||||
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
||||
|
||||
"isomorphic-streams@git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae":
|
||||
version "1.0.3"
|
||||
resolved "git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
||||
|
||||
typescript@^5.1.6:
|
||||
version "5.1.6"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274"
|
||||
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-browser",
|
||||
"version": "0.1.12",
|
||||
"version": "0.3.4",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.12",
|
||||
"jazz-storage-indexeddb": "^0.1.12",
|
||||
"cojson": "^0.3.4",
|
||||
"cojson-storage-indexeddb": "^0.3.4",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BinaryCoStream, InviteSecret } from "cojson";
|
||||
import { BinaryCoStream, CoValue, CoValueCore, InviteSecret } from "cojson";
|
||||
import { BinaryCoStreamMeta } from "cojson";
|
||||
import { MAX_RECOMMENDED_TX_SIZE } from "cojson";
|
||||
import { cojsonReady } from "cojson";
|
||||
import {
|
||||
LocalNode,
|
||||
@@ -9,12 +10,11 @@ import {
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
Peer,
|
||||
CoValueImpl,
|
||||
Group,
|
||||
CoID,
|
||||
} from "cojson";
|
||||
import { ReadableStream, WritableStream } from "isomorphic-streams";
|
||||
import { IDBStorage } from "jazz-storage-indexeddb";
|
||||
import { IDBStorage } from "cojson-storage-indexeddb";
|
||||
|
||||
export type BrowserNodeHandle = {
|
||||
node: LocalNode;
|
||||
@@ -25,7 +25,7 @@ export type BrowserNodeHandle = {
|
||||
export async function createBrowserNode({
|
||||
auth,
|
||||
syncAddress = "wss://sync.jazz.tools",
|
||||
reconnectionTimeout = 300,
|
||||
reconnectionTimeout: initialReconnectionTimeout = 500,
|
||||
}: {
|
||||
auth: AuthProvider;
|
||||
syncAddress?: string;
|
||||
@@ -37,6 +37,15 @@ export async function createBrowserNode({
|
||||
const firstWsPeer = createWebSocketPeer(syncAddress);
|
||||
let shouldTryToReconnect = true;
|
||||
|
||||
let currentReconnectionTimeout = initialReconnectionTimeout;
|
||||
|
||||
function onOnline() {
|
||||
console.log("Online, resetting reconnection timeout");
|
||||
currentReconnectionTimeout = initialReconnectionTimeout;
|
||||
}
|
||||
|
||||
window.addEventListener("online", onOnline);
|
||||
|
||||
const node = await auth.createNode(
|
||||
(accountID) => {
|
||||
const sessionHandle = getSessionHandleFor(accountID);
|
||||
@@ -49,19 +58,37 @@ export async function createBrowserNode({
|
||||
async function websocketReconnectLoop() {
|
||||
while (shouldTryToReconnect) {
|
||||
if (
|
||||
Object.keys(node.sync.peers).some((peerId) =>
|
||||
Object.keys(node.syncManager.peers).some((peerId) =>
|
||||
peerId.includes(syncAddress)
|
||||
)
|
||||
) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, reconnectionTimeout)
|
||||
);
|
||||
// TODO: this might drain battery, use listeners instead
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} else {
|
||||
console.log("Websocket disconnected, trying to reconnect");
|
||||
node.sync.addPeer(createWebSocketPeer(syncAddress));
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, reconnectionTimeout)
|
||||
console.log(
|
||||
"Websocket disconnected, trying to reconnect in " +
|
||||
currentReconnectionTimeout +
|
||||
"ms"
|
||||
);
|
||||
currentReconnectionTimeout = Math.min(
|
||||
currentReconnectionTimeout * 2,
|
||||
30000
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, currentReconnectionTimeout);
|
||||
window.addEventListener(
|
||||
"online",
|
||||
() => {
|
||||
console.log(
|
||||
"Online, trying to reconnect immediately"
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
|
||||
node.syncManager.addPeer(createWebSocketPeer(syncAddress));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +99,13 @@ export async function createBrowserNode({
|
||||
node,
|
||||
done: () => {
|
||||
shouldTryToReconnect = false;
|
||||
window.removeEventListener("online", onOnline);
|
||||
console.log("Cleaning up node");
|
||||
for (const peer of Object.values(node.syncManager.peers)) {
|
||||
peer.outgoing
|
||||
.close()
|
||||
.catch((e) => console.error("Error while closing peer", e));
|
||||
}
|
||||
sessionDone?.();
|
||||
},
|
||||
};
|
||||
@@ -285,13 +319,14 @@ function websocketWritableStream<T>(ws: WebSocket) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createInviteLink(
|
||||
value: CoValueImpl,
|
||||
export function createInviteLink<T extends CoValue>(
|
||||
value: T | { id: CoID<T>; core: CoValueCore },
|
||||
role: "reader" | "writer" | "admin",
|
||||
// default to same address as window.location, but without hash
|
||||
{
|
||||
baseURL = window.location.href.replace(/#.*$/, ""),
|
||||
}: { baseURL?: string } = {}
|
||||
valueHint,
|
||||
}: { baseURL?: string; valueHint?: string } = {}
|
||||
): string {
|
||||
const coValueCore = value.core;
|
||||
const node = coValueCore.node;
|
||||
@@ -312,29 +347,45 @@ export function createInviteLink(
|
||||
|
||||
const inviteSecret = group.createInvite(role);
|
||||
|
||||
return `${baseURL}#invitedTo=${value.id}&${inviteSecret}`;
|
||||
return `${baseURL}#/invite/${valueHint ? valueHint + "/" : ""}${
|
||||
value.id
|
||||
}/${inviteSecret}`;
|
||||
}
|
||||
|
||||
export function parseInviteLink<C extends CoValueImpl>(
|
||||
export function parseInviteLink<C extends CoValue>(
|
||||
inviteURL: string
|
||||
):
|
||||
| {
|
||||
valueID: CoID<C>;
|
||||
valueHint?: string;
|
||||
inviteSecret: InviteSecret;
|
||||
}
|
||||
| undefined {
|
||||
const url = new URL(inviteURL);
|
||||
const valueID = url.hash
|
||||
.split("&")[0]
|
||||
?.replace(/^#invitedTo=/, "") as CoID<C>;
|
||||
const inviteSecret = url.hash.split("&")[1] as InviteSecret;
|
||||
if (!valueID || !inviteSecret) {
|
||||
return undefined;
|
||||
const parts = url.hash.split("/");
|
||||
|
||||
let valueHint: string | undefined;
|
||||
let valueID: CoID<C> | undefined;
|
||||
let inviteSecret: InviteSecret | undefined;
|
||||
|
||||
if (parts[0] === "#" && parts[1] === "invite") {
|
||||
if (parts.length === 5) {
|
||||
valueHint = parts[2];
|
||||
valueID = parts[3] as CoID<C>;
|
||||
inviteSecret = parts[4] as InviteSecret;
|
||||
} else if (parts.length === 4) {
|
||||
valueID = parts[2] as CoID<C>;
|
||||
inviteSecret = parts[3] as InviteSecret;
|
||||
}
|
||||
|
||||
if (!valueID || !inviteSecret) {
|
||||
return undefined;
|
||||
}
|
||||
return { valueID, inviteSecret, valueHint };
|
||||
}
|
||||
return { valueID, inviteSecret };
|
||||
}
|
||||
|
||||
export function consumeInviteLinkFromWindowLocation<C extends CoValueImpl>(
|
||||
export function consumeInviteLinkFromWindowLocation<C extends CoValue>(
|
||||
node: LocalNode
|
||||
): Promise<
|
||||
| {
|
||||
@@ -382,17 +433,17 @@ export async function createBinaryStreamFromBlob<
|
||||
totalSizeBytes: blob.size,
|
||||
fileName: blob instanceof File ? blob.name : undefined,
|
||||
});
|
||||
}) as C;// TODO: fix this
|
||||
const chunkSize = 256 * 1024;
|
||||
}) as C; // TODO: fix this
|
||||
const chunkSize = MAX_RECOMMENDED_TX_SIZE;
|
||||
|
||||
for (let idx = 0; idx < data.length; idx += chunkSize) {
|
||||
stream = stream.edit((stream) => {
|
||||
stream.pushBinaryStreamChunk(
|
||||
data.slice(idx, idx + chunkSize)
|
||||
);
|
||||
}) as C; // TODO: fix this
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
for (let idx = 0; idx < data.length; idx += chunkSize) {
|
||||
stream = stream.edit((stream) => {
|
||||
stream.pushBinaryStreamChunk(
|
||||
data.slice(idx, idx + chunkSize)
|
||||
);
|
||||
}) as C; // TODO: fix this
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
stream = stream.edit((stream) => {
|
||||
stream.endBinaryStream();
|
||||
}) as C; // TODO: fix this
|
||||
@@ -419,15 +470,11 @@ export async function readBlobFromBinaryStream<
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chunks = stream.getBinaryChunks();
|
||||
const chunks = stream.getBinaryChunks(allowUnfinished);
|
||||
|
||||
if (!chunks) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!allowUnfinished && !chunks.finished) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Blob(chunks.chunks, { type: chunks.mimeType });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-react-auth-local",
|
||||
"version": "0.1.14",
|
||||
"version": "0.3.4",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser-auth-local": "^0.1.12",
|
||||
"jazz-react": "^0.1.14",
|
||||
"jazz-browser-auth-local": "^0.3.4",
|
||||
"jazz-react": "^0.3.4",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
17
packages/jazz-react-media-images/.eslintrc.cjs
Normal file
17
packages/jazz-react-media-images/.eslintrc.cjs
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
};
|
||||
171
packages/jazz-react-media-images/.gitignore
vendored
Normal file
171
packages/jazz-react-media-images/.gitignore
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
.DS_Store
|
||||
2
packages/jazz-react-media-images/.npmignore
Normal file
2
packages/jazz-react-media-images/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
coverage
|
||||
node_modules
|
||||
26
packages/jazz-react-media-images/package.json
Normal file
26
packages/jazz-react-media-images/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "jazz-react-media-images",
|
||||
"version": "0.3.4",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.3.4",
|
||||
"jazz-browser": "^0.3.4",
|
||||
"jazz-browser-media-images": "^0.3.4",
|
||||
"jazz-react": "^0.3.4",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.19"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "17 - 18"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.tsx",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
24
packages/jazz-react-media-images/src/index.tsx
Normal file
24
packages/jazz-react-media-images/src/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CoID, Media } from "cojson";
|
||||
import { loadImage, LoadingImageInfo } from "jazz-browser-media-images";
|
||||
import { useJazz } from "jazz-react";
|
||||
import { useEffect, useState } from "react";
|
||||
export { createImage, LoadingImageInfo } from "jazz-browser-media-images";
|
||||
|
||||
export function useLoadImage(
|
||||
imageID?: CoID<Media.ImageDefinition> | Media.ImageDefinition | {id: CoID<Media.ImageDefinition>},
|
||||
): LoadingImageInfo | undefined {
|
||||
const { localNode } = useJazz();
|
||||
|
||||
const [imageInfo, setImageInfo] = useState<LoadingImageInfo>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageID) return;
|
||||
const unsubscribe = loadImage(imageID, localNode, (imageInfo) => {
|
||||
setImageInfo(imageInfo);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [imageID, localNode]);
|
||||
|
||||
return imageInfo;
|
||||
}
|
||||
16
packages/jazz-react-media-images/tsconfig.json
Normal file
16
packages/jazz-react-media-images/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user