Compare commits
50 Commits
cojson@0.2
...
jazz-react
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27779ac792 | ||
|
|
3f1bfa4629 | ||
|
|
15a693c3ed | ||
|
|
b1d620e145 | ||
|
|
478fbd0aa9 | ||
|
|
ee906b7351 | ||
|
|
dd15f21ccb | ||
|
|
d7cd5fda7c | ||
|
|
174300b00f | ||
|
|
b2c8d8c855 | ||
|
|
2bad2b6bfe | ||
|
|
880d0ff855 | ||
|
|
e66cbee6cd | ||
|
|
03e470721e | ||
|
|
ecf73bcfa7 | ||
|
|
2c3a500286 | ||
|
|
8b83061cf4 | ||
|
|
e75c3207d6 | ||
|
|
41d4b5ba0b | ||
|
|
21fa1b168b | ||
|
|
91e5e7f2ab | ||
|
|
e3f7e2f1bd | ||
|
|
084cf80c60 | ||
|
|
632e3bbb08 | ||
|
|
17d17833b2 | ||
|
|
8e22bd9c1e | ||
|
|
98213743f3 | ||
|
|
bb855ed83d | ||
|
|
a8ef49e228 | ||
|
|
e0ad32dbd2 | ||
|
|
62bf769cad | ||
|
|
7488ff25b2 | ||
|
|
b69c9da983 | ||
|
|
d30fdef8aa | ||
|
|
9c5a6b9833 | ||
|
|
d300d265c4 | ||
|
|
1d72ce587f | ||
|
|
3fdb41dcb9 | ||
|
|
f20de2f04a | ||
|
|
31b31f111b | ||
|
|
2ae9fb9778 | ||
|
|
cd0da0f6bf | ||
|
|
cd9bfbb9fa | ||
|
|
ed0428bf97 | ||
|
|
c038a02051 | ||
|
|
31abcfeef4 | ||
|
|
5f32d9ccf5 | ||
|
|
0510600104 | ||
|
|
7f30fbf3c5 | ||
|
|
3d56260ca4 |
4
.github/workflows/build-and-deploy.yaml
vendored
4
.github/workflows/build-and-deploy.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
example: ["todo", "pets"]
|
||||
example: ["todo", "pets", "twit"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
needs: build
|
||||
strategy:
|
||||
matrix:
|
||||
example: ["todo", "pets"]
|
||||
example: ["todo", "pets", "twit"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
135
README.md
135
README.md
@@ -1,91 +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.
|
||||
|
||||
**`jazz-react-media-images`** → [DOCS](./DOCS.md#jazz-react-media-images)
|
||||
|
||||
TODO: document
|
||||
|
||||
**`jazz-browser-media-images`** → [DOCS](./DOCS.md#jazz-browser-media-images)
|
||||
|
||||
TODO: document
|
||||
|
||||
</small>
|
||||
Copyright 2023 — Garden Computing, Inc.
|
||||
@@ -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-pets",
|
||||
"private": true,
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,13 +16,15 @@
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.2.5",
|
||||
"jazz-react-auth-local": "^0.2.5",
|
||||
"jazz-react-media-images": "^0.2.5",
|
||||
"jazz-browser-media-images": "^0.4.1",
|
||||
"jazz-react": "^0.4.1",
|
||||
"jazz-react-auth-local": "^0.4.1",
|
||||
"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 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,11 @@
|
||||
import { CoMap, CoStream, Media } from "cojson";
|
||||
import {
|
||||
AccountMigration,
|
||||
CoList,
|
||||
CoMap,
|
||||
CoStream,
|
||||
Media,
|
||||
Profile,
|
||||
} from "cojson";
|
||||
|
||||
/** Walkthrough: Defining the data model with CoJSON
|
||||
*
|
||||
@@ -9,8 +16,8 @@ import { CoMap, CoStream, Media } from "cojson";
|
||||
|
||||
export type PetPost = CoMap<{
|
||||
name: string;
|
||||
image: Media.ImageDefinition;
|
||||
reactions: PetReactions;
|
||||
image: Media.ImageDefinition["id"];
|
||||
reactions: PetReactions["id"];
|
||||
}>;
|
||||
|
||||
export const REACTION_TYPES = [
|
||||
@@ -26,4 +33,20 @@ export type ReactionType = (typeof REACTION_TYPES)[number];
|
||||
|
||||
export type PetReactions = CoStream<ReactionType>;
|
||||
|
||||
export type ListOfPosts = CoList<PetPost["id"]>;
|
||||
|
||||
export type PetAccountRoot = CoMap<{
|
||||
posts: ListOfPosts["id"];
|
||||
}>;
|
||||
|
||||
export const migration: AccountMigration<Profile, PetAccountRoot> = (account) => {
|
||||
if (!account.get("root")) {
|
||||
const root = account.createMap<PetAccountRoot>({
|
||||
posts: account.createList<ListOfPosts>().id,
|
||||
});
|
||||
account.set("root", root.id);
|
||||
console.log("Created root", root.id);
|
||||
}
|
||||
};
|
||||
|
||||
/** Walkthrough: Continue with ./2_App.tsx */
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { PetPost } from "./1_types";
|
||||
|
||||
import { Button } from "./basicComponents";
|
||||
|
||||
import { useSimpleHashRouterThatAcceptsInvites } from "./router";
|
||||
import { RatePetPostUI } from "./4_RatePetPostUI";
|
||||
import { CreatePetPostForm } from "./3_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 ? (
|
||||
<RatePetPostUI petPostID={currentPetPostID} />
|
||||
) : (
|
||||
<CreatePetPostForm onCreate={navigateToPetPostID} />
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigateToPetPostID(undefined);
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: continue with ./3_CreatePetPostForm.tsx */
|
||||
119
examples/pets/src/2_main.tsx
Normal file
119
examples/pets/src/2_main.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import {
|
||||
Link,
|
||||
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";
|
||||
import { PetAccountRoot, migration } from "./1_types.ts";
|
||||
import { AccountMigration, Profile } from "cojson";
|
||||
|
||||
/** 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} />
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
<WithJazz auth={auth} migration={migration as AccountMigration}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</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: <PostOverview />,
|
||||
},
|
||||
{
|
||||
path: "/new",
|
||||
element: <NewPetPostForm />,
|
||||
},
|
||||
{
|
||||
path: "/pet/:petPostId",
|
||||
element: <RatePetPostUI />,
|
||||
},
|
||||
{
|
||||
path: "/invite/*",
|
||||
element: <p>Accepting invite...</p>,
|
||||
},
|
||||
]);
|
||||
|
||||
useAcceptInvite((petPostID) => router.navigate("/pet/" + petPostID));
|
||||
|
||||
return (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
|
||||
<Button
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostOverview() {
|
||||
const { me } = useJazz<Profile, PetAccountRoot>();
|
||||
|
||||
const myPosts = me.root?.posts;
|
||||
|
||||
return (
|
||||
<>
|
||||
root: {JSON.stringify(me.root?.coMap.asObject())}
|
||||
posts: {JSON.stringify(me.root?.posts?.coList?.asArray())}
|
||||
<h1>My posts</h1>
|
||||
{myPosts?.length
|
||||
? myPosts.map(
|
||||
(post) =>
|
||||
post && (
|
||||
<Link key={post.id} to={"/pet/" + post.id}>
|
||||
{post.name}
|
||||
</Link>
|
||||
)
|
||||
)
|
||||
: undefined}
|
||||
<Link to="/new">New post</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,48 @@
|
||||
import { ChangeEvent, useCallback, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { CoID, CoMap, Media } from "cojson";
|
||||
import { useJazz, useTelepathicQuery } from "jazz-react";
|
||||
import { createImage } from "jazz-browser-media-images";
|
||||
import { CoID, CoMap, Media, Profile } from "cojson";
|
||||
import { useJazz, useSyncedQuery } from "jazz-react";
|
||||
import { BrowserImage, createImage } from "jazz-browser-media-images";
|
||||
|
||||
import { PetPost, PetReactions } from "./1_types";
|
||||
import { PetAccountRoot, PetPost, 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;
|
||||
}>
|
||||
image?: Media.ImageDefinition["id"];
|
||||
reactions: PetReactions["id"];
|
||||
}>;
|
||||
|
||||
export function CreatePetPostForm({
|
||||
onCreate,
|
||||
}: {
|
||||
onCreate: (id: CoID<PetPost>) => void;
|
||||
}) {
|
||||
const { localNode } = useJazz();
|
||||
export function NewPetPostForm() {
|
||||
const { me } = useJazz<Profile, PetAccountRoot>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [newPostId, setNewPostId] = useState<CoID<PartialPetPost> | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [newPostId, setNewPostId] = useState<
|
||||
CoID<PartialPetPost> | undefined
|
||||
>(undefined);
|
||||
|
||||
const newPetPost = useTelepathicQuery(newPostId);
|
||||
const newPetPost = useSyncedQuery(newPostId);
|
||||
|
||||
const onChangeName = useCallback(
|
||||
(name: string) => {
|
||||
if (newPetPost) {
|
||||
newPetPost.edit((petPost) => {
|
||||
petPost.set("name", name);
|
||||
});
|
||||
newPetPost.set({ name });
|
||||
} else {
|
||||
const petPostGroup = localNode.createGroup();
|
||||
const petPostGroup = me.createGroup();
|
||||
const petPost = petPostGroup.createMap<PartialPetPost>({
|
||||
name,
|
||||
reactions: petPostGroup.createStream<PetReactions>(),
|
||||
reactions: petPostGroup.createStream<PetReactions>().id,
|
||||
});
|
||||
|
||||
setNewPostId(petPost.id);
|
||||
}
|
||||
},
|
||||
[localNode, newPetPost]
|
||||
[me, newPetPost]
|
||||
);
|
||||
|
||||
const onImageSelected = useCallback(
|
||||
@@ -59,14 +54,23 @@ export function CreatePetPostForm({
|
||||
newPetPost.group
|
||||
);
|
||||
|
||||
newPetPost.edit((petPost) => {
|
||||
petPost.set("image", image);
|
||||
});
|
||||
newPetPost.set({ image: image.id });
|
||||
},
|
||||
[newPetPost]
|
||||
);
|
||||
|
||||
const petImage = useLoadImage(newPetPost?.image?.id);
|
||||
const onSubmit = useCallback(() => {
|
||||
if (!newPetPost) return;
|
||||
const myPosts = me.root?.posts;
|
||||
|
||||
if (!myPosts) {
|
||||
throw new Error("No posts list found");
|
||||
}
|
||||
|
||||
myPosts.append(newPetPost.id as PetPost["id"]);
|
||||
|
||||
navigate("/pet/" + newPetPost.id);
|
||||
}, [me.root?.posts, newPetPost, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
@@ -79,10 +83,13 @@ export function CreatePetPostForm({
|
||||
value={newPetPost?.name || ""}
|
||||
/>
|
||||
|
||||
{petImage ? (
|
||||
{newPetPost?.image ? (
|
||||
<img
|
||||
className="w-80 max-w-full rounded"
|
||||
src={petImage.highestResSrc || petImage.placeholderDataURL}
|
||||
src={
|
||||
newPetPost?.image.as(BrowserImage)
|
||||
?.highestResSrcOrPlaceholder
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
@@ -93,13 +100,7 @@ export function CreatePetPostForm({
|
||||
)}
|
||||
|
||||
{newPetPost?.name && newPetPost?.image && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onCreate(newPetPost.id as CoID<PetPost>);
|
||||
}}
|
||||
>
|
||||
Submit Post
|
||||
</Button>
|
||||
<Button onClick={onSubmit}>Submit Post</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useParams } from "react-router";
|
||||
import { CoID, Queried } from "cojson";
|
||||
import { useTelepathicQuery } from "jazz-react";
|
||||
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 { BrowserImage } from "jazz-browser-media-images";
|
||||
import uniqolor from "uniqolor";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
@@ -20,9 +21,10 @@ const reactionEmojiMap: { [reaction in ReactionType]: string } = {
|
||||
chonkers: "🐘",
|
||||
};
|
||||
|
||||
export function RatePetPostUI({ petPostID }: { petPostID: CoID<PetPost> }) {
|
||||
const petPost = useTelepathicQuery(petPostID);
|
||||
const petImage = useLoadImage(petPost?.image);
|
||||
export function RatePetPostUI() {
|
||||
const petPostID = useParams<{ petPostId: CoID<PetPost> }>().petPostId;
|
||||
|
||||
const petPost = useSyncedQuery(petPostID);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -31,10 +33,13 @@ export function RatePetPostUI({ petPostID }: { petPostID: CoID<PetPost> }) {
|
||||
<ShareButton petPost={petPost} />
|
||||
</div>
|
||||
|
||||
{petImage && (
|
||||
{petPost?.image && (
|
||||
<img
|
||||
className="w-80 max-w-full rounded"
|
||||
src={petImage.highestResSrc || petImage.placeholderDataURL}
|
||||
src={
|
||||
petPost.image.as(BrowserImage)
|
||||
?.highestResSrcOrPlaceholder
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -48,9 +53,7 @@ export function RatePetPostUI({ petPostID }: { petPostID: CoID<PetPost> }) {
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => {
|
||||
petPost?.reactions?.edit((reactions) => {
|
||||
reactions.push(reactionType);
|
||||
});
|
||||
petPost?.reactions?.push(reactionType);
|
||||
}}
|
||||
title={`React with ${reactionType}`}
|
||||
className="text-2xl px-2"
|
||||
@@ -77,9 +80,9 @@ function ReactionOverview({
|
||||
<h2>Reactions</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{REACTION_TYPES.map((reactionType) => {
|
||||
const reactionsOfThisType = Object.values(
|
||||
petReactions.perAccount
|
||||
).filter(({ last }) => last === reactionType);
|
||||
const reactionsOfThisType = petReactions.perAccount
|
||||
.map(([, reaction]) => reaction)
|
||||
.filter(({ last }) => last === reactionType);
|
||||
|
||||
if (reactionsOfThisType.length === 0) return null;
|
||||
|
||||
@@ -89,16 +92,20 @@ function ReactionOverview({
|
||||
key={reactionType}
|
||||
>
|
||||
{reactionEmojiMap[reactionType]}{" "}
|
||||
{reactionsOfThisType.map((reaction) =>
|
||||
{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" />
|
||||
<Skeleton
|
||||
className="mt-1 w-[50px] h-[1em] rounded-full"
|
||||
key={idx}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CoID, LocalNode, CoValue } from "cojson";
|
||||
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
|
||||
|
||||
export function useSimpleHashRouterThatAcceptsInvites<C extends CoValue>(
|
||||
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.34",
|
||||
"version": "0.0.41",
|
||||
"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.2.5",
|
||||
"jazz-react-auth-local": "^0.2.5",
|
||||
"jazz-react": "^0.4.1",
|
||||
"jazz-react-auth-local": "^0.4.1",
|
||||
"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,26 +1,42 @@
|
||||
import { CoMap, CoList } from "cojson";
|
||||
import { CoMap, CoList, AccountMigration, Profile } 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
|
||||
**/
|
||||
|
||||
/** An individual task which collaborators can tick or rename */
|
||||
export type Task = CoMap<{ done: boolean; text: string; }>;
|
||||
|
||||
export type ListOfTasks = CoList<Task["id"]>;
|
||||
|
||||
/** Our top level object: a project with a title, referencing a list of tasks */
|
||||
export type TodoProject = CoMap<{
|
||||
title: string;
|
||||
/** A collaborative, ordered list of tasks */
|
||||
tasks: CoList<Task>;
|
||||
tasks: ListOfTasks["id"];
|
||||
}>;
|
||||
|
||||
/** Walkthrough: Continue with ./2_App.tsx */
|
||||
export type ListOfProjects = CoList<TodoProject["id"]>;
|
||||
|
||||
export type TodoAccountRoot = CoMap<{
|
||||
projects: ListOfProjects["id"];
|
||||
}>;
|
||||
|
||||
export const migration: AccountMigration<Profile, TodoAccountRoot> = (account) => {
|
||||
if (!account.get("root")) {
|
||||
account.set(
|
||||
"root",
|
||||
account.createMap<TodoAccountRoot>({
|
||||
projects: account.createList<ListOfProjects>().id,
|
||||
}).id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Walkthrough: Continue with ./2_main.tsx */
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { Task, TodoProject } from "./1_types";
|
||||
|
||||
import { SubmittableInput, Button } from "./basicComponents";
|
||||
|
||||
import { useSimpleHashRouterThatAcceptsInvites } from "./router";
|
||||
import { TodoTable } from "./3_TodoTable";
|
||||
import { CoList } from "cojson";
|
||||
|
||||
/** 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
|
||||
const project = projectGroup.createMap<TodoProject>({
|
||||
title,
|
||||
tasks: projectGroup.createList<CoList<Task>>()
|
||||
});
|
||||
|
||||
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 */
|
||||
94
examples/todo/src/2_main.tsx
Normal file
94
examples/todo/src/2_main.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
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";
|
||||
import { migration } from "./1_types.ts";
|
||||
import { AccountMigration } from "cojson";
|
||||
|
||||
/**
|
||||
* 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} />
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
<WithJazz auth={auth} migration={migration as AccountMigration}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</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 (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
|
||||
<Button
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: Continue with ./3_NewProjectForm.tsx */
|
||||
47
examples/todo/src/3_NewProjectForm.tsx
Normal file
47
examples/todo/src/3_NewProjectForm.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { ListOfTasks, TodoProject } from "./1_types";
|
||||
|
||||
import { SubmittableInput } from "./basicComponents";
|
||||
|
||||
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<ListOfTasks>().id,
|
||||
});
|
||||
|
||||
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,7 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { CoID, Queried } from "cojson";
|
||||
import { useTelepathicQuery } from "jazz-react";
|
||||
import { useSyncedQuery } from "jazz-react";
|
||||
|
||||
import { TodoProject, Task } from "./1_types";
|
||||
|
||||
@@ -19,35 +19,40 @@ import {
|
||||
|
||||
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 `useTelepathicData()` for the first time,
|
||||
* 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 TodoTable({ projectId }: { projectId: CoID<TodoProject> }) {
|
||||
// `useTelepathicData()` reactively subscribes to updates to a CoValue's
|
||||
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!
|
||||
const project = useTelepathicQuery(projectId);
|
||||
// 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 list of tasks/the project), and then
|
||||
// adding it as an item to the project's list of tasks.
|
||||
// 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>({
|
||||
text,
|
||||
const task = project.group.createMap<Task>({
|
||||
done: false,
|
||||
text,
|
||||
});
|
||||
|
||||
project?.tasks.edit((tasks) => {
|
||||
tasks.push(task);
|
||||
});
|
||||
// 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.id);
|
||||
},
|
||||
[project?.tasks, project?.group]
|
||||
);
|
||||
@@ -58,7 +63,7 @@ export function TodoTable({ projectId }: { projectId: CoID<TodoProject> }) {
|
||||
<h1>
|
||||
{
|
||||
// This is how we can access properties from the project query,
|
||||
// accounting for the fact that it might not be loaded yet
|
||||
// accounting for the fact that note everything might be loaded yet
|
||||
project?.title ? (
|
||||
<>
|
||||
{project.title}{" "}
|
||||
@@ -100,10 +105,10 @@ export function TaskRow({ task }: { task: Queried<Task> | undefined }) {
|
||||
className="mt-1"
|
||||
checked={task?.done}
|
||||
onCheckedChange={(checked) => {
|
||||
// (the only thing we let the user change is the "done" status)
|
||||
task?.edit((task) => {
|
||||
task.set("done", !!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>
|
||||
@@ -116,22 +121,28 @@ export function TaskRow({ task }: { task: Queried<Task> | undefined }) {
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
{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" />
|
||||
)}
|
||||
{
|
||||
// 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,
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CoID, LocalNode, CoValue } from "cojson";
|
||||
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
|
||||
|
||||
export function useSimpleHashRouterThatAcceptsInvites<C extends CoValue>(
|
||||
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;
|
||||
}
|
||||
18
examples/twit/.eslintrc.cjs
Normal file
18
examples/twit/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
24
examples/twit/.gitignore
vendored
Normal file
24
examples/twit/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
11
examples/twit/.prettierrc
Normal file
11
examples/twit/.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
4
examples/twit/Dockerfile
Normal file
4
examples/twit/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM caddy:2.7.3-alpine
|
||||
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
|
||||
|
||||
COPY ./dist /usr/share/caddy/
|
||||
64
examples/twit/README.md
Normal file
64
examples/twit/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Jazz Todo List Example
|
||||
|
||||
Live version: https://example-todo.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
|
||||
```
|
||||
|
||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- [`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
|
||||
|
||||
1. 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)
|
||||
|
||||
3. Creating a new todo project: [`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx)
|
||||
|
||||
4. Reactively rendering a todo project as a table, adding and editing tasks: [`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx)
|
||||
|
||||
### Helpers
|
||||
|
||||
- (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!
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
|
||||
## Configuration: sync server
|
||||
|
||||
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
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).
|
||||
16
examples/twit/components.json
Normal file
16
examples/twit/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/basicComponents",
|
||||
"utils": "@/basicComponents/lib/utils"
|
||||
}
|
||||
}
|
||||
13
examples/twit/index.html
Normal file
13
examples/twit/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jazz Todo List Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/2_main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
56
examples/twit/job-template.nomad
Normal file
56
examples/twit/job-template.nomad
Normal file
@@ -0,0 +1,56 @@
|
||||
job "twit$BRANCH_SUFFIX" {
|
||||
region = "global"
|
||||
datacenters = ["*"]
|
||||
|
||||
group "static" {
|
||||
count = 8
|
||||
|
||||
network {
|
||||
port "http" {
|
||||
to = 80
|
||||
}
|
||||
}
|
||||
|
||||
constraint {
|
||||
attribute = "${node.class}"
|
||||
operator = "="
|
||||
value = "mesh"
|
||||
}
|
||||
|
||||
spread {
|
||||
attribute = "${node.datacenter}"
|
||||
weight = 100
|
||||
}
|
||||
|
||||
constraint {
|
||||
distinct_hosts = true
|
||||
}
|
||||
|
||||
task "server" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "$DOCKER_TAG"
|
||||
ports = ["http"]
|
||||
|
||||
auth = {
|
||||
username = "$DOCKER_USER"
|
||||
password = "$DOCKER_PASSWORD"
|
||||
}
|
||||
}
|
||||
|
||||
service {
|
||||
tags = ["public"]
|
||||
name = "twit$BRANCH_SUFFIX"
|
||||
port = "http"
|
||||
provider = "consul"
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 50 # MHz
|
||||
memory = 50 # MB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# deploy bump 4
|
||||
50
examples/twit/package.json
Normal file
50
examples/twit/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "jazz-example-twit",
|
||||
"private": true,
|
||||
"version": "0.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"javascript-time-ago": "^2.5.9",
|
||||
"jazz-browser-media-images": "^0.4.1",
|
||||
"jazz-react": "^0.4.1",
|
||||
"jazz-react-auth-local": "^0.4.1",
|
||||
"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",
|
||||
"react-time-ago": "^7.2.1",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uniqolor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
6
examples/twit/postcss.config.js
Normal file
6
examples/twit/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
examples/twit/public/jazz-logo.png
Normal file
BIN
examples/twit/public/jazz-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
64
examples/twit/src/1_dataModel.ts
Normal file
64
examples/twit/src/1_dataModel.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { CoMap, CoList, Media, CoStream, Group, AccountMigration, EVERYONE, Profile } from 'cojson';
|
||||
|
||||
export type Twit = CoMap<{
|
||||
text?: string;
|
||||
images?: ListOfImages['id'];
|
||||
likes: LikeStream['id'];
|
||||
replies: ReplyStream['id'];
|
||||
isReplyTo?: Twit['id'];
|
||||
}>;
|
||||
|
||||
export type ListOfImages = CoList<Media.ImageDefinition['id']>;
|
||||
export type LikeStream = CoStream<'❤️' | null>;
|
||||
export type ReplyStream = CoStream<Twit['id']>;
|
||||
|
||||
export type ListOfTwits = CoList<Twit['id']>;
|
||||
export type ListOfProfiles = CoList<TwitProfile['id']>;
|
||||
export type StreamOfFollowers = CoStream<TwitProfile['id'] | null>;
|
||||
|
||||
export type TwitProfile = Profile<
|
||||
{
|
||||
name: string;
|
||||
bio: string;
|
||||
avatar?: Media.ImageDefinition['id'];
|
||||
twits: ListOfTwits['id'];
|
||||
following: ListOfProfiles['id'];
|
||||
followers: StreamOfFollowers['id'];
|
||||
twitStyle?: { fontFamily: string; color: string };
|
||||
}
|
||||
>;
|
||||
|
||||
export type TwitAccountRoot = CoMap<{
|
||||
peopleWhoCanSeeMyTwits: Group['id'];
|
||||
peopleWhoCanSeeMyFollows: Group['id'];
|
||||
peopleWhoCanFollowMe: Group['id'];
|
||||
peopleWhoCanInteractWithMe: Group['id'];
|
||||
}>;
|
||||
|
||||
export const migration: AccountMigration<TwitProfile, TwitAccountRoot> = (account, profile) => {
|
||||
if (!account.get('root')) {
|
||||
const peopleWhoCanSeeMyTwits = account.createGroup();
|
||||
const peopleWhoCanSeeMyFollows = account.createGroup();
|
||||
const peopleWhoCanFollowMe = account.createGroup();
|
||||
const peopleWhoCanInteractWithMe = account.createGroup();
|
||||
|
||||
peopleWhoCanFollowMe?.addMember(EVERYONE, 'writer');
|
||||
peopleWhoCanSeeMyTwits?.addMember(EVERYONE, 'reader');
|
||||
peopleWhoCanSeeMyFollows?.addMember(EVERYONE, 'reader');
|
||||
peopleWhoCanInteractWithMe?.addMember(EVERYONE, 'writer');
|
||||
|
||||
const root = account.createMap<TwitAccountRoot>({
|
||||
peopleWhoCanSeeMyTwits: peopleWhoCanSeeMyTwits.id,
|
||||
peopleWhoCanSeeMyFollows: peopleWhoCanSeeMyFollows.id,
|
||||
peopleWhoCanFollowMe: peopleWhoCanFollowMe.id,
|
||||
peopleWhoCanInteractWithMe: peopleWhoCanInteractWithMe.id
|
||||
});
|
||||
|
||||
account.set('root', root.id);
|
||||
|
||||
profile.set('twits', peopleWhoCanSeeMyTwits.createList<ListOfTwits>().id, 'trusting');
|
||||
profile.set('following', peopleWhoCanSeeMyFollows.createList<ListOfProfiles>().id, 'trusting');
|
||||
profile.set('followers', peopleWhoCanFollowMe.createStream<StreamOfFollowers>().id, 'trusting');
|
||||
console.log('MIGRATION SUCCESSFUL!');
|
||||
}
|
||||
};
|
||||
71
examples/twit/src/2_main.tsx
Normal file
71
examples/twit/src/2_main.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { RouterProvider, createHashRouter } from 'react-router-dom';
|
||||
import './index.css';
|
||||
|
||||
import { AccountMigration } from 'cojson';
|
||||
import { WithJazz, useJazz } from 'jazz-react';
|
||||
import { LocalAuth } from 'jazz-react-auth-local';
|
||||
|
||||
import { Button, ThemeProvider, TitleAndLogo } from './basicComponents/index.tsx';
|
||||
import { PrettyAuthUI } from './components/Auth.tsx';
|
||||
|
||||
import { migration } from './1_dataModel.ts';
|
||||
import { ChronoFeed } from './3_ChronoFeed.tsx';
|
||||
import { ProfilePage } from './5_ProfilePage.tsx';
|
||||
|
||||
const appName = 'Jazz Twit Example';
|
||||
|
||||
const auth = LocalAuth({
|
||||
appName,
|
||||
Component: PrettyAuthUI
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
<div className="flex flex-col h-full items-stretch justify-start gap-10 pt-10 pb-10 px-5 w-full max-w-xl mx-auto">
|
||||
<WithJazz auth={auth} migration={migration as AccountMigration}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
function App() {
|
||||
const { me, logOut } = useJazz();
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <ChronoFeed />
|
||||
},
|
||||
{
|
||||
path: '/:profileId',
|
||||
element: <ProfilePage />
|
||||
},
|
||||
{
|
||||
path: '/me',
|
||||
loader: () => router.navigate('/' + me.profile?.id)
|
||||
}
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => router.navigate('/')} variant="link" className="-ml-3">
|
||||
Home
|
||||
</Button>
|
||||
<Button onClick={() => router.navigate('/me')} variant="link" className="ml-auto">
|
||||
My Profile
|
||||
</Button>
|
||||
<Button onClick={() => router.navigate('/').then(logOut)} variant="outline">
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
<RouterProvider router={router} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
examples/twit/src/3_ChronoFeed.tsx
Normal file
35
examples/twit/src/3_ChronoFeed.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useJazz } from 'jazz-react';
|
||||
import { TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
|
||||
import { CreateTwitForm } from './6_CreateTwitForm.tsx';
|
||||
import { TwitComponent } from './4_TwitComponent.tsx';
|
||||
import { MainH1 } from './basicComponents/index.tsx';
|
||||
|
||||
export function ChronoFeed() {
|
||||
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
|
||||
|
||||
const myTwits = me.profile?.twits;
|
||||
|
||||
const twitsFromFollows = useMemo(
|
||||
() => me.profile?.following?.flatMap(follow => follow?.twits || []) || [],
|
||||
[me.profile?.following]
|
||||
);
|
||||
|
||||
const allTwitsSorted = useMemo(
|
||||
() =>
|
||||
[...(myTwits || []), ...twitsFromFollows]
|
||||
.flatMap(tw => (tw ? (tw.isReplyTo ? [] : tw) : []))
|
||||
.sort((a, b) => (b.edits.text?.at?.getTime() || 0) - (a.edits.text?.at?.getTime() || 0)),
|
||||
[myTwits, twitsFromFollows]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch">
|
||||
<CreateTwitForm className="mb-10" />
|
||||
<MainH1>From people you follow</MainH1>
|
||||
{allTwitsSorted?.map(twit => (
|
||||
<TwitComponent twit={twit} key={twit.id} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
examples/twit/src/4_TwitComponent.tsx
Normal file
101
examples/twit/src/4_TwitComponent.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ButtonWithCount,
|
||||
ProfilePicImg,
|
||||
ReactionsContainer,
|
||||
RepliesContainer,
|
||||
SubtleRelativeTimeAgo,
|
||||
TwitContainer,
|
||||
TwitWithRepliesContainer,
|
||||
TwitImg,
|
||||
TwitImgGallery,
|
||||
TwitHeader,
|
||||
TwitBody,
|
||||
TwitText,
|
||||
} from './basicComponents/index.tsx';
|
||||
import { Twit, TwitProfile } from './1_dataModel.ts';
|
||||
import { Queried } from 'cojson';
|
||||
import { BrowserImage } from 'jazz-browser-media-images';
|
||||
import { HeartIcon, MessagesSquareIcon } from 'lucide-react';
|
||||
import { CreateTwitForm } from './6_CreateTwitForm.tsx';
|
||||
|
||||
export function TwitComponent({
|
||||
twit,
|
||||
alreadyInReplies: alreadyInReplies
|
||||
}: {
|
||||
twit?: Queried<Twit>;
|
||||
alreadyInReplies?: boolean;
|
||||
}) {
|
||||
const [showReplyForm, setShowReplyForm] = React.useState(false);
|
||||
|
||||
const posterProfile = twit?.edits.text?.by?.profile as Queried<TwitProfile> | undefined;
|
||||
const isTopLevel = !twit?.isReplyTo || alreadyInReplies;
|
||||
|
||||
return (
|
||||
<TwitWithRepliesContainer isTopLevel={isTopLevel}>
|
||||
<TwitContainer>
|
||||
<ProfilePicImg
|
||||
src={posterProfile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
|
||||
linkTo={'/' + posterProfile?.id}
|
||||
initial={posterProfile?.name[0]}
|
||||
size={twit?.isReplyTo && "sm"}
|
||||
/>
|
||||
|
||||
<TwitBody>
|
||||
<TwitHeader>
|
||||
<Link to={'/' + posterProfile?.id} className="font-bold hover:underline">
|
||||
{posterProfile?.name}
|
||||
</Link>
|
||||
<SubtleRelativeTimeAgo dateTime={twit?.edits.text?.at} />
|
||||
</TwitHeader>
|
||||
|
||||
<TwitText style={posterProfile?.twitStyle}>
|
||||
{/* This is where the tweet text goes */}
|
||||
{twit?.text}
|
||||
</TwitText>
|
||||
|
||||
{twit?.images && (
|
||||
<TwitImgGallery>
|
||||
{twit.images.map(image => (
|
||||
<TwitImg src={image?.as(BrowserImage)?.highestResSrcOrPlaceholder} key={image?.id} />
|
||||
))}
|
||||
</TwitImgGallery>
|
||||
)}
|
||||
|
||||
<ReactionsContainer>
|
||||
<ButtonWithCount
|
||||
active={twit?.likes?.me?.last === '❤️'}
|
||||
onClick={() => twit?.likes?.push(twit?.likes?.me?.last ? null : '❤️')}
|
||||
count={twit?.likes?.perAccount.filter(([, liked]) => liked.last === '❤️').length || 0}
|
||||
icon={<HeartIcon size="18" />}
|
||||
activeIcon={<HeartIcon color="red" size="18" fill="red" />}
|
||||
/>
|
||||
<ButtonWithCount
|
||||
onClick={() => setShowReplyForm(s => !s)}
|
||||
count={twit?.replies?.perAccount.flatMap(([, byAccount]) => byAccount.all).length || 0}
|
||||
icon={<MessagesSquareIcon size="18" />}
|
||||
/>
|
||||
</ReactionsContainer>
|
||||
</TwitBody>
|
||||
</TwitContainer>
|
||||
|
||||
<RepliesContainer>
|
||||
{showReplyForm && (
|
||||
<CreateTwitForm
|
||||
inReplyTo={twit}
|
||||
onSubmit={() => setShowReplyForm(false)}
|
||||
className={'mt-5 ' + (isTopLevel ? 'ml-14' : 'ml-12')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{twit?.replies?.perAccount
|
||||
.flatMap(([, byAccount]) => byAccount.all)
|
||||
.sort((a, b) => b.at.getTime() - a.at.getTime())
|
||||
.map(replyEntry => (
|
||||
<TwitComponent twit={replyEntry.value} key={replyEntry.value?.id} alreadyInReplies={!!twit?.isReplyTo} />
|
||||
))}
|
||||
</RepliesContainer>
|
||||
</TwitWithRepliesContainer>
|
||||
);
|
||||
}
|
||||
127
examples/twit/src/5_ProfilePage.tsx
Normal file
127
examples/twit/src/5_ProfilePage.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useJazz, useSyncedQuery } from 'jazz-react';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
BioInput,
|
||||
ChooseProfilePicInput,
|
||||
FollowerStatsContainer,
|
||||
Popover,
|
||||
ProfileName,
|
||||
ProfilePicImg,
|
||||
ProfileTitleContainer,
|
||||
SmallInlineButton
|
||||
} from './basicComponents/index.tsx';
|
||||
import { TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
|
||||
import { CoID } from 'cojson';
|
||||
import { BrowserImage, createImage } from 'jazz-browser-media-images';
|
||||
import { FollowButton, FollowerList, FollowingList } from './7_FollowStuff.tsx';
|
||||
import { CreateTwitForm } from './6_CreateTwitForm.tsx';
|
||||
import { TwitComponent } from './4_TwitComponent.tsx';
|
||||
import { PopoverContent, PopoverTrigger } from '@radix-ui/react-popover';
|
||||
|
||||
export function ProfilePage() {
|
||||
const { profileId } = useParams<{ profileId: CoID<TwitProfile> }>();
|
||||
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
|
||||
|
||||
const profile = useSyncedQuery(profileId);
|
||||
const isMe = profile?.id == me.profile?.id;
|
||||
|
||||
const profileTwitsAndRepliedToTwits = useMemo(() => {
|
||||
return profile?.twits?.map((twit, _, allTwits) =>
|
||||
twit?.isReplyTo
|
||||
? allTwits.some(
|
||||
tw =>
|
||||
tw?.id === twit?.isReplyTo?.id ||
|
||||
tw?.id === twit?.isReplyTo?.isReplyTo?.id ||
|
||||
tw?.id === twit?.isReplyTo?.isReplyTo?.isReplyTo?.id
|
||||
)
|
||||
? null
|
||||
: twit?.isReplyTo
|
||||
: twit
|
||||
);
|
||||
}, [profile?.twits]);
|
||||
|
||||
const [qr, setQr] = useState<string>('');
|
||||
useEffect(() => {
|
||||
QRCode.toDataURL(
|
||||
window.location.protocol + '//' + window.location.host + window.location.pathname + '#/' + profile?.id,
|
||||
{
|
||||
errorCorrectionLevel: 'L'
|
||||
}
|
||||
).then(setQr);
|
||||
}, [profile?.id]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="py-2 mb-5 flex gap-4">
|
||||
<div className="flex flex-col items-stretch">
|
||||
<ProfilePicImg
|
||||
src={profile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
|
||||
initial={profile?.name[0]}
|
||||
size="xxl"
|
||||
/>
|
||||
{isMe && (
|
||||
<ChooseProfilePicInput
|
||||
onChange={(file: File) =>
|
||||
me.root?.peopleWhoCanSeeMyTwits &&
|
||||
createImage(file, me.root.peopleWhoCanSeeMyTwits.group, 256).then(image => {
|
||||
me.profile?.set({ avatar: image.id }, 'trusting');
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grow">
|
||||
<ProfileTitleContainer>
|
||||
<ProfileName>{profile?.name}</ProfileName>
|
||||
{!isMe && <FollowButton profile={profile} />}
|
||||
</ProfileTitleContainer>
|
||||
|
||||
<div>
|
||||
{isMe ? (
|
||||
<BioInput
|
||||
value={profile?.bio}
|
||||
onChange={newBio => {
|
||||
profile?.set({ bio: newBio }, 'trusting');
|
||||
// prettier-ignore
|
||||
if (newBio.startsWith('{')) { profile?.set('twitStyle', JSON.parse(newBio), 'trusting'); } else { profile?.set('twitStyle', undefined, 'trusting'); }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
profile?.bio || '(No bio)'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FollowerStatsContainer>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<SmallInlineButton>
|
||||
{profile?.followers?.perAccount?.filter(([, status]) => status.last).length} Followers
|
||||
</SmallInlineButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<FollowerList profile={profile} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="hidden md:block">—</span> <br className="md:hidden" />
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<SmallInlineButton>{new Set(profile?.following || []).size} Following</SmallInlineButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<FollowingList profile={profile} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FollowerStatsContainer>
|
||||
</div>
|
||||
|
||||
{isMe && <img src={qr} className="rounded w-28 h-28 -mr-3 dark:invert max-sm:w-16 max-sm:h-16" />}
|
||||
</div>
|
||||
|
||||
{isMe && <CreateTwitForm className="mb-4" />}
|
||||
|
||||
{profileTwitsAndRepliedToTwits?.map(twit => twit && <TwitComponent twit={twit} key={twit?.id} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
examples/twit/src/6_CreateTwitForm.tsx
Normal file
74
examples/twit/src/6_CreateTwitForm.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useJazz } from 'jazz-react';
|
||||
import { AddTwitPicsInput, TwitImg, TwitTextInput } from './basicComponents/index.tsx';
|
||||
import { LikeStream, ListOfImages, ReplyStream, Twit, TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
|
||||
import { Queried } from 'cojson';
|
||||
import { createImage } from 'jazz-browser-media-images';
|
||||
|
||||
export function CreateTwitForm(
|
||||
props: {
|
||||
inReplyTo?: Queried<Twit>;
|
||||
onSubmit?: () => void;
|
||||
className?: string;
|
||||
} = {}
|
||||
) {
|
||||
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
|
||||
|
||||
const [pics, setPics] = React.useState<File[]>([]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(twitText: string) => {
|
||||
const audience = me.root?.peopleWhoCanSeeMyTwits;
|
||||
const interactors = me.root?.peopleWhoCanInteractWithMe;
|
||||
if (!audience || !interactors) return;
|
||||
|
||||
const twit = audience.createMap<Twit>({
|
||||
text: twitText,
|
||||
likes: interactors.createStream<LikeStream>().id,
|
||||
replies: interactors.createStream<ReplyStream>().id
|
||||
});
|
||||
|
||||
me.profile?.twits?.prepend(twit?.id as Twit['id']);
|
||||
|
||||
if (props.inReplyTo) {
|
||||
props.inReplyTo.replies?.push(twit.id);
|
||||
twit.set({ isReplyTo: props.inReplyTo.id });
|
||||
}
|
||||
|
||||
Promise.all(pics.map(pic => createImage(pic, twit.group, 1024))).then(createdPics => {
|
||||
twit.set({ images: audience.createList<ListOfImages>(createdPics.map(pic => pic.id)).id });
|
||||
});
|
||||
|
||||
setPics([]);
|
||||
props.onSubmit?.();
|
||||
},
|
||||
[me.profile?.twits, me.root?.peopleWhoCanSeeMyTwits, me.root?.peopleWhoCanInteractWithMe, props, pics]
|
||||
);
|
||||
|
||||
const [picPreviews, setPicPreviews] = React.useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const previews = pics.map(pic => URL.createObjectURL(pic));
|
||||
setPicPreviews(previews);
|
||||
return () => previews.forEach(preview => URL.revokeObjectURL(preview));
|
||||
}, [pics]);
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<TwitTextInput onSubmit={onSubmit} submitButtonLabel={props.inReplyTo ? 'Reply!' : 'Twit!'} />
|
||||
|
||||
{picPreviews.length ? (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{picPreviews.map(preview => (
|
||||
<TwitImg src={preview} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<AddTwitPicsInput
|
||||
onChange={(newPics: File[]) => {
|
||||
setPics(newPics);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
examples/twit/src/7_FollowStuff.tsx
Normal file
81
examples/twit/src/7_FollowStuff.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useJazz } from 'jazz-react';
|
||||
import { Button, ProfilePicImg } from './basicComponents/index.tsx';
|
||||
import { TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
|
||||
import { Queried } from 'cojson';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BrowserImage } from 'jazz-browser-media-images';
|
||||
|
||||
export function FollowButton({ profile }: { profile?: Queried<TwitProfile> }) {
|
||||
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
|
||||
|
||||
const alreadyFollowing = profile?.followers?.perAccount?.some(([acc, status]) => acc === me.id && !!status.last);
|
||||
const theyFollowMe = profile?.following?.some(f => f?.id === me.profile?.id);
|
||||
|
||||
const followOrUnfollow = useCallback(() => {
|
||||
if (!profile?.followers || !me.profile?.following) return;
|
||||
if (alreadyFollowing) {
|
||||
me.profile.following.delete(me.profile.following.findIndex(f => f?.id === profile.id));
|
||||
profile.followers.push(null);
|
||||
} else {
|
||||
me.profile.following.append(profile.id);
|
||||
profile.followers.push(me.profile.id);
|
||||
}
|
||||
}, [alreadyFollowing, me.profile, profile]);
|
||||
|
||||
return profile?.id === me.profile?.id ? (
|
||||
<div className="ml-auto text-neutral-500">That's you!</div>
|
||||
) : (
|
||||
<Button onClick={followOrUnfollow} className="ml-auto" variant={alreadyFollowing ? 'ghost' : 'default'}>
|
||||
{alreadyFollowing ? 'Unfollow' : theyFollowMe ? 'Follow Back' : 'Follow'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FollowerList({ profile }: { profile?: Queried<TwitProfile> }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 bg-background rounded-lg border shadow-lg w-96 max-w-full m-2">
|
||||
{profile?.followers?.perAccount.map(([, followEntry]) => {
|
||||
const followerProfile = followEntry.last;
|
||||
// not following anymore?
|
||||
if (!followerProfile) return null;
|
||||
|
||||
return (
|
||||
<div key={followerProfile.id} className="flex items-center">
|
||||
<ProfilePicImg
|
||||
src={followerProfile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
|
||||
linkTo={'/' + followerProfile?.id}
|
||||
initial={followerProfile?.name[0]}
|
||||
/>
|
||||
<Link to={'/' + followerProfile?.id} className="font-bold hover:underline">
|
||||
{followerProfile?.name}
|
||||
</Link>
|
||||
<FollowButton profile={followerProfile} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FollowingList({ profile }: { profile?: Queried<TwitProfile> }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 bg-background rounded-lg border shadow-lg w-96 max-w-full m-2">
|
||||
{[...new Set(profile?.following || [])].map(followingProfile => {
|
||||
return (
|
||||
<div key={followingProfile?.id} className="flex items-center">
|
||||
<ProfilePicImg
|
||||
src={followingProfile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
|
||||
linkTo={'/' + followingProfile?.id}
|
||||
initial={followingProfile?.name[0]}
|
||||
/>
|
||||
<Link to={'/' + followingProfile?.id} className="font-bold hover:underline">
|
||||
{followingProfile?.name}
|
||||
</Link>
|
||||
<FollowButton profile={followingProfile} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
examples/twit/src/basicComponents/SubmittableInput.tsx
Normal file
39
examples/twit/src/basicComponents/SubmittableInput.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Input } from "@/basicComponents/ui/input";
|
||||
import { Button } from "@/basicComponents/ui/button";
|
||||
|
||||
export function SubmittableInput({
|
||||
onSubmit,
|
||||
label,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: {
|
||||
onSubmit: (text: string) => void;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
className="flex flex-row items-center gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const textEl = e.currentTarget.elements.namedItem(
|
||||
"text"
|
||||
) as HTMLInputElement;
|
||||
onSubmit(textEl.value);
|
||||
textEl.value = "";
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="-ml-3 -my-2 flex-grow flex-3 text-base"
|
||||
name="text"
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button asChild type="submit" className="flex-shrink flex-1 cursor-pointer">
|
||||
<Input type="submit" value={label} disabled={disabled} />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
10
examples/twit/src/basicComponents/TitleAndLogo.tsx
Normal file
10
examples/twit/src/basicComponents/TitleAndLogo.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
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 />
|
||||
</>
|
||||
}
|
||||
228
examples/twit/src/basicComponents/index.tsx
Normal file
228
examples/twit/src/basicComponents/index.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import ReactTimeAgo from 'react-time-ago';
|
||||
import { Button, ButtonProps } from './ui/button';
|
||||
export { Button } from './ui/button';
|
||||
export { Checkbox } from './ui/checkbox';
|
||||
import { Input } from './ui/input';
|
||||
import { Link } from 'react-router-dom';
|
||||
export { Input } from './ui/input';
|
||||
export { Skeleton } from './ui/skeleton';
|
||||
export { Toaster } from './ui/toaster';
|
||||
export { useToast } from './ui/use-toast';
|
||||
export { SubmittableInput } from './SubmittableInput';
|
||||
export { TitleAndLogo } from './TitleAndLogo';
|
||||
export { ThemeProvider } from './themeProvider';
|
||||
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/card';
|
||||
export { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
|
||||
import TimeAgo from 'javascript-time-ago';
|
||||
import en from 'javascript-time-ago/locale/en.json';
|
||||
TimeAgo.addDefaultLocale(en);
|
||||
|
||||
export function BioInput(props: { value?: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={props.value}
|
||||
autoComplete="off"
|
||||
onChange={e => {
|
||||
props.onChange(e.target.value);
|
||||
}}
|
||||
placeholder="Add a bio..."
|
||||
className="w-full p-2 border rounded max-md:text-base"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileTitleContainer(props: { children: React.ReactNode }) {
|
||||
return <div className="flex items-baseline">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function ProfileName(props: { children: React.ReactNode }) {
|
||||
return <h1 className="text-2xl">{props.children}</h1>;
|
||||
}
|
||||
|
||||
export function FollowerStatsContainer(props: { children: React.ReactNode }) {
|
||||
return <div className="flex gap-2 mt-2 text-neutral-500">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function ChooseProfilePicInput(props: { onChange: (file: File) => void }) {
|
||||
return (
|
||||
<Button asChild className="mt-2" size="sm" variant="secondary">
|
||||
<label className="cursor-pointer text-xs">
|
||||
Choose Pic
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={e => {
|
||||
e.target.files?.[0] && props.onChange(e.target.files[0]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilePicImg(props: { src?: string; size?: 'sm' | 'xxl'; linkTo?: string; initial?: string }) {
|
||||
return (
|
||||
<Link to={props.linkTo || ''}>
|
||||
{props.src ? (
|
||||
<img
|
||||
src={props.src}
|
||||
className={
|
||||
'bg-neutral-200 rounded-full mr-2 object-cover shrink-0' +
|
||||
(props.size === 'sm' ? ' w-8 h-8' : props.size === 'xxl' ? ' w-20 h-20' : ' w-10 h-10')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
'bg-neutral-200 rounded-full mr-2 object-cover shrink-0 flex items-center justify-center text-neutral-700 ' +
|
||||
(props.size === 'sm'
|
||||
? ' w-8 h-8 text-[1.5rem]'
|
||||
: props.size === 'xxl'
|
||||
? ' w-20 h-20 text-[3.75rem]'
|
||||
: ' w-10 h-10 text-[1.875rem]')
|
||||
}
|
||||
>
|
||||
<div className="-mt-[8%]">{props.initial}</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubtleRelativeTimeAgo(props: { dateTime?: Date }) {
|
||||
return (
|
||||
<div className="ml-auto text-neutral-300 text-xs whitespace-nowrap">
|
||||
<ReactTimeAgo date={props.dateTime || 0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwitImg(props: { src?: string }) {
|
||||
return <img src={props.src} className="h-40 rounded object-cover" />;
|
||||
}
|
||||
|
||||
export function ReactionsContainer(props: { children: React.ReactNode }) {
|
||||
return <div className="flex gap-4 mt-2">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function RepliesContainer(props: { children: React.ReactNode }) {
|
||||
return <div className="flex flex-col items-stretch gap-2 mt-2">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function ButtonWithCount(props: {
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
icon: React.ReactNode;
|
||||
activeIcon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="w-10 h-7 p-1 mr-1"
|
||||
variant={props.active ? 'secondary' : 'outline'}
|
||||
onClick={props.onClick}
|
||||
size="icon"
|
||||
>
|
||||
{props.active ? props.activeIcon : props.icon}
|
||||
</Button>{' '}
|
||||
<span className="tabular-nums">{props.count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwitTextInput(props: { onSubmit: (text: string) => void; submitButtonLabel: string }) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={event => {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target as HTMLFormElement;
|
||||
const text = form.twitText.value;
|
||||
text && props.onSubmit(text);
|
||||
form.twitText.value = '';
|
||||
}}
|
||||
className="flex gap-2 items-end"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
name="twitText"
|
||||
placeholder="What's happenin'"
|
||||
autoComplete="off"
|
||||
className="p-2 border rounded grow max-md:text-base"
|
||||
/>
|
||||
<Button asChild>
|
||||
<input type="submit" value={props.submitButtonLabel} />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddTwitPicsInput(props: { onChange: (files: File[]) => void }) {
|
||||
return (
|
||||
<Button asChild className="mt-2" size="sm" variant="secondary">
|
||||
<label className="cursor-pointer text-xs">
|
||||
Add Pics
|
||||
<Input
|
||||
type="file"
|
||||
onChange={e => {
|
||||
props.onChange(Array.from(e.target.files || []));
|
||||
}}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwitWithRepliesContainer(props: { children: React.ReactNode; isTopLevel?: boolean }) {
|
||||
return (
|
||||
<div className={'py-2 flex flex-col items-stretch' + (props.isTopLevel ? ' border-t' : ' ml-14')}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwitContainer(props: { children: React.ReactNode }) {
|
||||
return <div className="flex gap-2">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function TwitBody(props: { children: React.ReactNode }) {
|
||||
return <div className="grow flex flex-col items-stretch">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function TwitHeader(props: { children: React.ReactNode }) {
|
||||
return <div className="flex items-baseline">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function TwitImgGallery(props: { children: React.ReactNode }) {
|
||||
return <div className="flex gap-2 mt-2 max-w-full overflow-auto">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function TwitText(props: { children: React.ReactNode; style?: React.CSSProperties }) {
|
||||
return <div style={props.style}>{props.children}</div>;
|
||||
}
|
||||
|
||||
export function QuoteContainer(props: { children: React.ReactNode }) {
|
||||
return <div className="border rounded">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function MainH1(props: { children: React.ReactNode }) {
|
||||
return <h1 className="text-2xl mb-4">{props.children}</h1>;
|
||||
}
|
||||
|
||||
export function SmallInlineButton(props: { children: React.ReactNode } & ButtonProps) {
|
||||
const {children, ...rest} = props
|
||||
return (
|
||||
<Button variant={'ghost'} className="h-6 px-1 -mx-1" {...rest}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
6
examples/twit/src/basicComponents/lib/utils.ts
Normal file
6
examples/twit/src/basicComponents/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
72
examples/twit/src/basicComponents/themeProvider.tsx
Normal file
72
examples/twit/src/basicComponents/themeProvider.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: string;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: string;
|
||||
setTheme: (theme: string) => void;
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState(
|
||||
() => localStorage.getItem(storageKey) || defaultTheme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)"
|
||||
).matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: string) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
56
examples/twit/src/basicComponents/ui/button.tsx
Normal file
56
examples/twit/src/basicComponents/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
examples/twit/src/basicComponents/ui/card.tsx
Normal file
79
examples/twit/src/basicComponents/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
28
examples/twit/src/basicComponents/ui/checkbox.tsx
Normal file
28
examples/twit/src/basicComponents/ui/checkbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
25
examples/twit/src/basicComponents/ui/input.tsx
Normal file
25
examples/twit/src/basicComponents/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
29
examples/twit/src/basicComponents/ui/popover.tsx
Normal file
29
examples/twit/src/basicComponents/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
15
examples/twit/src/basicComponents/ui/skeleton.tsx
Normal file
15
examples/twit/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 }
|
||||
114
examples/twit/src/basicComponents/ui/table.tsx
Normal file
114
examples/twit/src/basicComponents/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn("bg-primary font-medium text-primary-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
127
examples/twit/src/basicComponents/ui/toast.tsx
Normal file
127
examples/twit/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/twit/src/basicComponents/ui/toaster.tsx
Normal file
33
examples/twit/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/twit/src/basicComponents/ui/use-toast.ts
Normal file
192
examples/twit/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 }
|
||||
48
examples/twit/src/components/Auth.tsx
Normal file
48
examples/twit/src/components/Auth.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||
|
||||
import { Input, Button } from "../basicComponents";
|
||||
|
||||
export const PrettyAuthUI: LocalAuthComponent = ({
|
||||
loading,
|
||||
logIn,
|
||||
signUp,
|
||||
}) => {
|
||||
const [username, setUsername] = useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-5">
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<div className="w-72 flex flex-col gap-4">
|
||||
<form
|
||||
className="w-72 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
signUp(username);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="webauthn"
|
||||
className="text-base"
|
||||
/>
|
||||
<Button asChild>
|
||||
<Input
|
||||
type="submit"
|
||||
value="Sign Up as new account"
|
||||
/>
|
||||
</Button>
|
||||
</form>
|
||||
<Button onClick={logIn}>
|
||||
Log In with existing account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
76
examples/twit/src/index.css
Normal file
76
examples/twit/src/index.css
Normal file
@@ -0,0 +1,76 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--primary: 24 9.8% 10%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--primary: 60 9.1% 97.8%;
|
||||
--primary-foreground: 24 9.8% 10%;
|
||||
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 24 5.7% 82.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
1
examples/twit/src/vite-env.d.ts
vendored
Normal file
1
examples/twit/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
76
examples/twit/tailwind.config.js
Normal file
76
examples/twit/tailwind.config.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
29
examples/twit/tsconfig.json
Normal file
29
examples/twit/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
examples/twit/tsconfig.node.json
Normal file
10
examples/twit/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
examples/twit/vite.config.ts
Normal file
16
examples/twit/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: false
|
||||
}
|
||||
})
|
||||
609
generateDocs.ts
609
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,22 +8,17 @@ 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-browser": "index.ts",
|
||||
"jazz-browser-media-images": "index.ts",
|
||||
"jazz-react-media-images": "index.tsx",
|
||||
}).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"
|
||||
]
|
||||
groupOrder: ["Functions", "Classes", "TypeAliases", "Namespaces"],
|
||||
categorizeByGroup: false
|
||||
});
|
||||
|
||||
const project = await app.convert();
|
||||
@@ -43,38 +38,65 @@ 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")
|
||||
: child.kind === 4
|
||||
? child.groups
|
||||
?.map((group) =>
|
||||
renderChildGroup(child, group)
|
||||
)
|
||||
.join("\n\n")
|
||||
.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)
|
||||
);
|
||||
@@ -84,7 +106,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
|
||||
@@ -95,11 +117,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"
|
||||
@@ -107,13 +168,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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,29 +226,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 isNamespace = child.kind === 4;
|
||||
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"
|
||||
: isNamespace
|
||||
? "namespace"
|
||||
: ""
|
||||
} ${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
|
||||
@@ -176,7 +262,7 @@ async function main() {
|
||||
: "") +
|
||||
(isClass || isInterface || isNamespace
|
||||
? " {...}"
|
||||
: isTypeDef
|
||||
: isTypeAlias
|
||||
? ` = ${renderType(child.type)}`
|
||||
: child.signatures
|
||||
? `(${(child.signatures[0].parameters || [])
|
||||
@@ -189,13 +275,13 @@ 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
|
||||
@@ -203,10 +289,14 @@ async function main() {
|
||||
|
||||
if (member.kind === 2048 || member.kind === 512) {
|
||||
if (
|
||||
member.signatures?.every((sig) =>
|
||||
sig.comment?.modifierTags?.includes(
|
||||
"@internal"
|
||||
)
|
||||
member.signatures?.every(
|
||||
(sig) =>
|
||||
sig.comment?.modifierTags?.includes(
|
||||
"@internal"
|
||||
) ||
|
||||
sig.comment?.modifierTags?.includes(
|
||||
"@deprecated"
|
||||
)
|
||||
)
|
||||
) {
|
||||
return "";
|
||||
@@ -223,6 +313,9 @@ async function main() {
|
||||
if (
|
||||
member.comment?.modifierTags?.includes(
|
||||
"@internal"
|
||||
) ||
|
||||
member.comment?.modifierTags?.includes(
|
||||
"@deprecated"
|
||||
)
|
||||
) {
|
||||
return "";
|
||||
@@ -233,11 +326,17 @@ async function main() {
|
||||
if (
|
||||
member.comment?.modifierTags?.includes(
|
||||
"@internal"
|
||||
) ||
|
||||
member.comment?.modifierTags?.includes(
|
||||
"@deprecated"
|
||||
)
|
||||
) {
|
||||
return "";
|
||||
} else {
|
||||
return documentProperty({...member, flags: {isStatic: true}}, child);
|
||||
return documentProperty(
|
||||
{ ...member, flags: { isStatic: true } },
|
||||
child
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return "Unknown member kind " + member.kind;
|
||||
@@ -261,9 +360,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) +
|
||||
@@ -274,42 +403,77 @@ 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 ? "?" : ""
|
||||
}: ${renderType(child.type)}`
|
||||
)
|
||||
.join(", ") + ", " : ""}[` +
|
||||
`{${
|
||||
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";
|
||||
}
|
||||
@@ -346,9 +510,55 @@ async function main() {
|
||||
return "AgentID";
|
||||
}
|
||||
} else {
|
||||
return "`" + t.head + t.tail.map(bit => "${" + renderType(bit[0]) + "}" + bit[1]).join("") + "`";
|
||||
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;
|
||||
}
|
||||
@@ -391,99 +601,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)"}</summary>\n\n` +
|
||||
} ${
|
||||
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.2.6",
|
||||
"version": "0.4.1",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/ws": "^8.5.5",
|
||||
@@ -16,8 +16,8 @@
|
||||
"typescript": "5.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cojson": "^0.2.3",
|
||||
"cojson-storage-sqlite": "^0.2.6",
|
||||
"cojson": "^0.4.1",
|
||||
"cojson-storage-sqlite": "^0.4.1",
|
||||
"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.2.5",
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.4.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.2.3",
|
||||
"cojson": "^0.4.1",
|
||||
"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" })
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Peer,
|
||||
CojsonInternalTypes,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
AccountID,
|
||||
} from "cojson";
|
||||
import {
|
||||
ReadableStream,
|
||||
@@ -141,7 +142,9 @@ export class IDBStorage {
|
||||
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 transaction = (
|
||||
ev.target as unknown as { transaction: IDBTransaction }
|
||||
).transaction;
|
||||
|
||||
const txsStore = transaction.objectStore("transactions");
|
||||
const txs = await promised(txsStore.getAll());
|
||||
@@ -266,7 +269,8 @@ export class IDBStorage {
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: idx,
|
||||
lastSignature: "WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
lastSignature:
|
||||
"WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
newTransactions: [],
|
||||
};
|
||||
newContentPieces[newContentPieces.length - 1]!.new[
|
||||
@@ -328,7 +332,25 @@ export class IDBStorage {
|
||||
})
|
||||
)
|
||||
: coValueRow?.header.ruleset.type === "ownedByGroup"
|
||||
? [coValueRow?.header.ruleset.group]
|
||||
? [
|
||||
coValueRow?.header.ruleset.group,
|
||||
...new Set(
|
||||
newContentPieces.flatMap((piece) =>
|
||||
Object.keys(piece)
|
||||
.map((sessionID) =>
|
||||
cojsonInternals.accountOrAgentIDfromSessionID(
|
||||
sessionID as SessionID
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
(accountID): accountID is AccountID =>
|
||||
cojsonInternals.isAccountID(
|
||||
accountID
|
||||
) && accountID !== theirKnown.id
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
: [];
|
||||
|
||||
for (const dependedOnCoValue of dependedOnCoValues) {
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.2.6",
|
||||
"version": "0.4.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.5.2",
|
||||
"cojson": "^0.2.3",
|
||||
"cojson": "^0.4.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CojsonInternalTypes,
|
||||
SessionID,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
AccountID
|
||||
} from "cojson";
|
||||
import {
|
||||
ReadableStream,
|
||||
@@ -237,19 +238,31 @@ export class SQLiteStorage {
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
const parsedHeader = (coValueRow?.header &&
|
||||
JSON.parse(coValueRow.header)) as
|
||||
| CojsonInternalTypes.CoValueHeader
|
||||
| undefined;
|
||||
let parsedHeader;
|
||||
|
||||
const newContentPieces: 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;
|
||||
@@ -265,7 +278,10 @@ export class SQLiteStorage {
|
||||
.prepare<[number, number]>(
|
||||
`SELECT * FROM signatureAfter WHERE ses = ? AND idx >= ?`
|
||||
)
|
||||
.all(sessionRow.rowID, firstNewTxIdx) as SignatureAfterRow[];
|
||||
.all(
|
||||
sessionRow.rowID,
|
||||
firstNewTxIdx
|
||||
) as SignatureAfterRow[];
|
||||
|
||||
// console.log(
|
||||
// theirKnown.id,
|
||||
@@ -295,7 +311,8 @@ export class SQLiteStorage {
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: idx,
|
||||
lastSignature: "WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
lastSignature:
|
||||
"WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
newTransactions: [],
|
||||
};
|
||||
newContentPieces[newContentPieces.length - 1]!.new[
|
||||
@@ -303,7 +320,21 @@ export class SQLiteStorage {
|
||||
] = sessionEntry;
|
||||
}
|
||||
|
||||
sessionEntry.newTransactions.push(JSON.parse(tx.tx));
|
||||
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] &&
|
||||
@@ -331,30 +362,65 @@ export class SQLiteStorage {
|
||||
const dependedOnCoValues =
|
||||
parsedHeader?.ruleset.type === "group"
|
||||
? newContentPieces
|
||||
.flatMap((piece) => Object.values(piece.new)).flatMap((sessionEntry) =>
|
||||
sessionEntry.newTransactions.flatMap((tx) => {
|
||||
if (tx.privacy !== "trusting") return [];
|
||||
// TODO: avoid parsing 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_")
|
||||
);
|
||||
})
|
||||
)
|
||||
.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]
|
||||
? [
|
||||
parsedHeader?.ruleset.group,
|
||||
...new Set(
|
||||
newContentPieces.flatMap((piece) =>
|
||||
Object.keys(piece)
|
||||
.map((sessionID) =>
|
||||
cojsonInternals.accountOrAgentIDfromSessionID(
|
||||
sessionID as SessionID
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
(accountID): accountID is AccountID =>
|
||||
cojsonInternals.isAccountID(accountID) &&
|
||||
accountID !== theirKnown.id
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
: [];
|
||||
|
||||
for (const dependedOnCoValue of dependedOnCoValues) {
|
||||
@@ -499,7 +565,7 @@ export class SQLiteStorage {
|
||||
sessionUpdate.sessionID,
|
||||
sessionUpdate.lastIdx,
|
||||
sessionUpdate.lastSignature,
|
||||
sessionUpdate.bytesSinceLastSignature,
|
||||
sessionUpdate.bytesSinceLastSignature
|
||||
) as { rowID: number };
|
||||
|
||||
const sessionRowID = upsertedSession.rowID;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.2.3",
|
||||
"version": "0.4.1",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
BinaryCoStreamMeta,
|
||||
CoStream,
|
||||
} from "./coValues/coStream.js";
|
||||
import { Static } from "./coValues/static.js";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import { CoValueCore } from "./coValueCore.js";
|
||||
import { Group } from "./group.js";
|
||||
import { Group } from "./coValues/group.js";
|
||||
import { Account, Profile } from "./index.js";
|
||||
|
||||
export type CoID<T extends CoValue> = RawCoID & {
|
||||
readonly __type: T;
|
||||
@@ -27,6 +27,7 @@ export interface CoValue {
|
||||
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.
|
||||
@@ -35,56 +36,41 @@ export interface CoValue {
|
||||
*
|
||||
* Used internally by `useTelepathicData()` for reactive updates on changes to a `CoValue`. */
|
||||
subscribe(listener: (coValue: this) => 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: CoValue) => void) => this) | undefined;
|
||||
}
|
||||
|
||||
export type AnyCoMap = CoMap<
|
||||
{ [key: string]: JsonValue | CoValue | undefined },
|
||||
JsonObject | null
|
||||
>;
|
||||
export type AnyCoValue = CoMap | Group | Account | Profile | CoList | CoStream | BinaryCoStream;
|
||||
|
||||
export type AnyCoList = CoList<JsonValue | CoValue, JsonObject | null>;
|
||||
|
||||
export type AnyCoStream = CoStream<JsonValue | CoValue, JsonObject | null>;
|
||||
|
||||
export type AnyBinaryCoStream = BinaryCoStream<BinaryCoStreamMeta>;
|
||||
|
||||
export type AnyStatic = Static<JsonObject>;
|
||||
|
||||
export type AnyCoValue =
|
||||
| AnyCoMap
|
||||
| AnyCoList
|
||||
| AnyCoStream
|
||||
| AnyBinaryCoStream
|
||||
| AnyStatic;
|
||||
|
||||
export function expectMap(
|
||||
content: CoValue
|
||||
): CoMap<{ [key: string]: string }, JsonObject | null> {
|
||||
export function expectMap(content: CoValue): CoMap {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<{ [key: string]: string }, JsonObject | null>;
|
||||
return content as CoMap;
|
||||
}
|
||||
|
||||
export function isCoValueImpl(
|
||||
value: JsonValue | AnyCoValue | undefined
|
||||
): value is AnyCoValue {
|
||||
export function expectList(content: CoValue): CoList {
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
return content as CoList;
|
||||
}
|
||||
|
||||
export function expectStream(content: CoValue): CoStream {
|
||||
if (content.type !== "costream") {
|
||||
throw new Error("Expected stream");
|
||||
}
|
||||
|
||||
return content as CoStream;
|
||||
}
|
||||
|
||||
export function isCoValue(
|
||||
value: JsonValue | CoValue | undefined
|
||||
): value is CoValue {
|
||||
return (
|
||||
value instanceof CoMap ||
|
||||
value instanceof CoList ||
|
||||
value instanceof CoStream ||
|
||||
value instanceof BinaryCoStream ||
|
||||
value instanceof Static
|
||||
value instanceof BinaryCoStream
|
||||
);
|
||||
}
|
||||
|
||||
export function isCoValue(value: JsonValue | CoValue | undefined) : value is CoValue {
|
||||
return isCoValueImpl(value as AnyCoValue);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
import { AnyCoValue } 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 {
|
||||
@@ -27,12 +26,17 @@ import {
|
||||
determineValidTransactions,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { Group, expectGroup } from "./coValues/group.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 {
|
||||
Account,
|
||||
AccountID,
|
||||
GeneralizedControlledAccount,
|
||||
isAccountID,
|
||||
} from "./coValues/account.js";
|
||||
import { Stringified, stableStringify } from "./jsonStringify.js";
|
||||
|
||||
export const MAX_RECOMMENDED_TX_SIZE = 100 * 1024;
|
||||
@@ -99,8 +103,8 @@ export class CoValueCore {
|
||||
node: LocalNode;
|
||||
header: CoValueHeader;
|
||||
_sessions: { [key: SessionID]: SessionLog };
|
||||
_cachedContent?: AnyCoValue;
|
||||
listeners: Set<(content?: AnyCoValue) => void> = new Set();
|
||||
_cachedContent?: CoValue;
|
||||
listeners: Set<(content?: CoValue) => void> = new Set();
|
||||
_decryptionCache: {
|
||||
[key: Encrypted<JsonValue[], JsonValue>]:
|
||||
| Stringified<JsonValue[]>
|
||||
@@ -164,7 +168,15 @@ export class CoValueCore {
|
||||
}
|
||||
|
||||
nextTransactionID(): TransactionID {
|
||||
const sessionID = this.node.currentSessionID;
|
||||
// This is an ugly hack to get a unique but stable session ID for editing the current account
|
||||
const sessionID =
|
||||
this.header.meta?.type === "account"
|
||||
? (this.node.currentSessionID.replace(
|
||||
this.node.account.id,
|
||||
this.node.account.currentAgentID()
|
||||
) as SessionID)
|
||||
: this.node.currentSessionID;
|
||||
|
||||
return {
|
||||
sessionID,
|
||||
txIndex: this.sessions[sessionID]?.transactions.length || 0,
|
||||
@@ -376,7 +388,7 @@ export class CoValueCore {
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (content?: AnyCoValue) => void): () => void {
|
||||
subscribe(listener: (content?: CoValue) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getCurrentContent());
|
||||
|
||||
@@ -468,7 +480,14 @@ export class CoValueCore {
|
||||
};
|
||||
}
|
||||
|
||||
const sessionID = this.node.currentSessionID;
|
||||
// This is an ugly hack to get a unique but stable session ID for editing the current account
|
||||
const sessionID =
|
||||
this.header.meta?.type === "account"
|
||||
? (this.node.currentSessionID.replace(
|
||||
this.node.account.id,
|
||||
this.node.account.currentAgentID()
|
||||
) as SessionID)
|
||||
: this.node.currentSessionID;
|
||||
|
||||
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
|
||||
transaction,
|
||||
@@ -487,41 +506,57 @@ export class CoValueCore {
|
||||
);
|
||||
|
||||
if (success) {
|
||||
void this.node.sync.syncCoValue(this);
|
||||
void this.node.syncManager.syncCoValue(this);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
getCurrentContent(): AnyCoValue {
|
||||
if (this._cachedContent) {
|
||||
getCurrentContent(options?: { ignorePrivateTransactions: true }): CoValue {
|
||||
if (!options?.ignorePrivateTransactions && this._cachedContent) {
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
let newContent;
|
||||
if (this.header.type === "comap") {
|
||||
this._cachedContent = new CoMap(this);
|
||||
if (this.header.ruleset.type === "group") {
|
||||
if (
|
||||
this.header.meta?.type === "account" &&
|
||||
!options?.ignorePrivateTransactions
|
||||
) {
|
||||
newContent = new Account(this);
|
||||
} else {
|
||||
newContent = new Group(this, options);
|
||||
}
|
||||
} else {
|
||||
newContent = new CoMap(this);
|
||||
}
|
||||
} else if (this.header.type === "colist") {
|
||||
this._cachedContent = new CoList(this);
|
||||
newContent = new CoList(this);
|
||||
} else if (this.header.type === "costream") {
|
||||
if (this.header.meta && this.header.meta.type === "binary") {
|
||||
this._cachedContent = new BinaryCoStream(this);
|
||||
newContent = new BinaryCoStream(this);
|
||||
} else {
|
||||
this._cachedContent = new CoStream(this);
|
||||
newContent = new CoStream(this);
|
||||
}
|
||||
} else if (this.header.type === "static") {
|
||||
this._cachedContent = new Static(this);
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||
}
|
||||
|
||||
return this._cachedContent;
|
||||
if (!options?.ignorePrivateTransactions) {
|
||||
this._cachedContent = newContent;
|
||||
}
|
||||
|
||||
return newContent;
|
||||
}
|
||||
|
||||
getValidSortedTransactions(): DecryptedTransaction[] {
|
||||
getValidSortedTransactions(options?: {
|
||||
ignorePrivateTransactions: true;
|
||||
}): DecryptedTransaction[] {
|
||||
const validTransactions = determineValidTransactions(this);
|
||||
|
||||
const allTransactions: DecryptedTransaction[] = validTransactions
|
||||
.map(({ txID, tx }) => {
|
||||
.flatMap(({ txID, tx }) => {
|
||||
if (tx.privacy === "trusting") {
|
||||
return {
|
||||
txID,
|
||||
@@ -529,6 +564,9 @@ export class CoValueCore {
|
||||
changes: tx.changes,
|
||||
};
|
||||
} else {
|
||||
if (options?.ignorePrivateTransactions) {
|
||||
return undefined;
|
||||
}
|
||||
const readKey = this.getReadKey(tx.keyUsed);
|
||||
|
||||
if (!readKey) {
|
||||
@@ -577,7 +615,7 @@ export class CoValueCore {
|
||||
|
||||
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
|
||||
if (this.header.ruleset.type === "group") {
|
||||
const content = expectGroupContent(this.getCurrentContent());
|
||||
const content = expectGroup(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.get("readKey");
|
||||
|
||||
@@ -603,45 +641,58 @@ export class CoValueCore {
|
||||
}
|
||||
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (readKeyCache.get(this)?.[keyID]) {
|
||||
return readKeyCache.get(this)?.[keyID];
|
||||
let key = readKeyCache.get(this)?.[keyID];
|
||||
if (!key) {
|
||||
key = this.getUncachedReadKey(keyID);
|
||||
if (key) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = key;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
getUncachedReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (this.header.ruleset.type === "group") {
|
||||
const content = expectGroupContent(this.getCurrentContent());
|
||||
|
||||
// Try to find key revelation for us
|
||||
|
||||
const readKeyEntry = content.getLastEntry(
|
||||
`${keyID}_for_${this.node.account.id}`
|
||||
const content = expectGroup(
|
||||
this.getCurrentContent({ ignorePrivateTransactions: true })
|
||||
);
|
||||
|
||||
if (readKeyEntry) {
|
||||
const revealer = accountOrAgentIDfromSessionID(
|
||||
readKeyEntry.txID.sessionID
|
||||
);
|
||||
const keyForEveryone = content.get(`${keyID}_for_everyone`);
|
||||
if (keyForEveryone) return keyForEveryone;
|
||||
|
||||
// Try to find key revelation for us
|
||||
const lookupAccountOrAgentID =
|
||||
this.header.meta?.type === "account"
|
||||
? this.node.account.currentAgentID()
|
||||
: this.node.account.id;
|
||||
|
||||
const lastReadyKeyEdit = content.lastEditAt(
|
||||
`${keyID}_for_${lookupAccountOrAgentID}`
|
||||
);
|
||||
|
||||
if (lastReadyKeyEdit?.value) {
|
||||
const revealer = lastReadyKeyEdit.by;
|
||||
const revealerAgent = this.node.resolveAccountAgent(
|
||||
revealer,
|
||||
"Expected to know revealer"
|
||||
);
|
||||
|
||||
const secret = unseal(
|
||||
readKeyEntry.value,
|
||||
lastReadyKeyEdit.value,
|
||||
this.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: readKeyEntry.txID,
|
||||
tx: lastReadyKeyEdit.tx,
|
||||
}
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = secret;
|
||||
|
||||
return secret as KeySecret;
|
||||
}
|
||||
}
|
||||
@@ -670,13 +721,6 @@ export class CoValueCore {
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = secret;
|
||||
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
console.error(
|
||||
@@ -703,13 +747,10 @@ export class CoValueCore {
|
||||
throw new Error("Only values owned by groups have groups");
|
||||
}
|
||||
|
||||
return new Group(
|
||||
expectGroupContent(
|
||||
this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentContent()
|
||||
),
|
||||
return expectGroup(
|
||||
this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentContent()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -784,15 +825,16 @@ export class CoValueCore {
|
||||
sessionEntry = {
|
||||
after: sentState[sessionID] ?? 0,
|
||||
newTransactions: [],
|
||||
lastSignature: "WILL_BE_REPLACED" as Signature
|
||||
lastSignature: "WILL_BE_REPLACED" as Signature,
|
||||
};
|
||||
currentPiece.new[sessionID] = sessionEntry;
|
||||
}
|
||||
|
||||
sessionEntry.newTransactions.push(...txsToAdd);
|
||||
sessionEntry.lastSignature = nextKnownSignatureIdx === undefined
|
||||
? log.lastSignature!
|
||||
: log.signatureAfter[nextKnownSignatureIdx]!
|
||||
sessionEntry.lastSignature =
|
||||
nextKnownSignatureIdx === undefined
|
||||
? log.lastSignature!
|
||||
: log.signatureAfter[nextKnownSignatureIdx]!;
|
||||
|
||||
sentState[sessionID] =
|
||||
(sentState[sessionID] || 0) + txsToAdd.length;
|
||||
@@ -812,11 +854,25 @@ export class CoValueCore {
|
||||
|
||||
getDependedOnCoValues(): RawCoID[] {
|
||||
return this.header.ruleset.type === "group"
|
||||
? expectGroupContent(this.getCurrentContent())
|
||||
? expectGroup(this.getCurrentContent())
|
||||
.keys()
|
||||
.filter((k): k is AccountID => k.startsWith("co_"))
|
||||
: this.header.ruleset.type === "ownedByGroup"
|
||||
? [this.header.ruleset.group]
|
||||
? [
|
||||
this.header.ruleset.group,
|
||||
...new Set(
|
||||
Object.keys(this._sessions)
|
||||
.map((sessionID) =>
|
||||
accountOrAgentIDfromSessionID(
|
||||
sessionID as SessionID
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
(session): session is AccountID =>
|
||||
isAccountID(session) && session !== this.id
|
||||
)
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CoValueHeader } from "./coValueCore.js";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { CoValueCore, CoValueHeader } from "../coValueCore.js";
|
||||
import { CoID, CoValue } from "../coValue.js";
|
||||
import {
|
||||
AgentSecret,
|
||||
SealerID,
|
||||
@@ -11,11 +11,10 @@ import {
|
||||
getAgentSealerSecret,
|
||||
getAgentSignerID,
|
||||
getAgentSignerSecret,
|
||||
} from "./crypto.js";
|
||||
import { AgentID } from "./ids.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { Group, GroupContent } from "./group.js";
|
||||
} from "../crypto.js";
|
||||
import { AgentID } from "../ids.js";
|
||||
import { CoMap } from "./coMap.js";
|
||||
import { Group, InviteSecret } from "./group.js";
|
||||
|
||||
export function accountHeaderForInitialAgentSecret(
|
||||
agentSecret: AgentSecret
|
||||
@@ -32,15 +31,15 @@ export function accountHeaderForInitialAgentSecret(
|
||||
};
|
||||
}
|
||||
|
||||
export class AccountGroup extends Group {
|
||||
get id(): AccountID {
|
||||
return this.underlyingMap.id as AccountID;
|
||||
}
|
||||
|
||||
export class Account<
|
||||
P extends Profile = Profile,
|
||||
R extends CoMap = CoMap,
|
||||
Meta extends AccountMeta = AccountMeta
|
||||
> extends Group<P, R, Meta> {
|
||||
getCurrentAgentID(): AgentID {
|
||||
const agents = this.underlyingMap
|
||||
.keys()
|
||||
.filter((k): k is AgentID => k.startsWith("sealer_"));
|
||||
const agents = this.keys().filter((k): k is AgentID =>
|
||||
k.startsWith("sealer_")
|
||||
);
|
||||
|
||||
if (agents.length !== 1) {
|
||||
throw new Error(
|
||||
@@ -64,22 +63,37 @@ export interface GeneralizedControlledAccount {
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
export class ControlledAccount
|
||||
extends AccountGroup
|
||||
export class ControlledAccount<
|
||||
P extends Profile = Profile,
|
||||
R extends CoMap = CoMap,
|
||||
Meta extends AccountMeta = AccountMeta
|
||||
>
|
||||
extends Account<P, R, Meta>
|
||||
implements GeneralizedControlledAccount
|
||||
{
|
||||
agentSecret: AgentSecret;
|
||||
|
||||
constructor(
|
||||
agentSecret: AgentSecret,
|
||||
groupMap: CoMap<AccountContent, AccountMeta>,
|
||||
node: LocalNode
|
||||
) {
|
||||
super(groupMap, node);
|
||||
constructor(core: CoValueCore, agentSecret: AgentSecret) {
|
||||
super(core);
|
||||
|
||||
this.agentSecret = agentSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new group (with the current account as the group's first admin).
|
||||
* @category 1. High-level
|
||||
*/
|
||||
createGroup() {
|
||||
return this.core.node.createGroup();
|
||||
}
|
||||
|
||||
async acceptInvite<T extends CoValue>(
|
||||
groupOrOwnedValueID: CoID<T>,
|
||||
inviteSecret: InviteSecret
|
||||
): Promise<void> {
|
||||
return this.core.node.acceptInvite(groupOrOwnedValueID, inviteSecret);
|
||||
}
|
||||
|
||||
currentAgentID(): AgentID {
|
||||
return getAgentID(this.agentSecret);
|
||||
}
|
||||
@@ -136,17 +150,22 @@ export class AnonymousControlledAccount
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountContent = { profile: Profile } & GroupContent;
|
||||
export type AccountMeta = { type: "account" };
|
||||
export type Account = CoMap<AccountContent, AccountMeta>;
|
||||
export type AccountID = CoID<Account>;
|
||||
|
||||
export function isAccountID(id: AccountID | AgentID): id is AccountID {
|
||||
return id.startsWith("co_");
|
||||
}
|
||||
|
||||
export type ProfileContent = {
|
||||
export type ProfileShape = {
|
||||
name: string;
|
||||
};
|
||||
export type ProfileMeta = { type: "profile" };
|
||||
export type Profile = CoMap<ProfileContent, ProfileMeta>;
|
||||
|
||||
export class Profile<Shape extends ProfileShape = ProfileShape, Meta extends ProfileMeta = ProfileMeta> extends CoMap<Shape, Meta> {
|
||||
|
||||
}
|
||||
|
||||
export type AccountMigration< P extends Profile = Profile,
|
||||
R extends CoMap = CoMap,
|
||||
Meta extends AccountMeta = AccountMeta> = (account: ControlledAccount<P, R, Meta>, profile: P) => void;
|
||||
@@ -1,22 +1,22 @@
|
||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { CoID, CoValue, isCoValue } from "../coValue.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { SessionID, TransactionID } from "../ids.js";
|
||||
import { Group } from "../group.js";
|
||||
import { AccountID, isAccountID } from "../account.js";
|
||||
import { AgentID, SessionID, TransactionID } from "../ids.js";
|
||||
import { AccountID } from "./account.js";
|
||||
import { parseJSON } from "../jsonStringify.js";
|
||||
import { Group } from "./group.js";
|
||||
|
||||
type OpID = TransactionID & { changeIdx: number };
|
||||
|
||||
type InsertionOpPayload<T extends JsonValue | CoValue> =
|
||||
type InsertionOpPayload<T extends JsonValue> =
|
||||
| {
|
||||
op: "pre";
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
||||
value: T;
|
||||
before: OpID | "end";
|
||||
}
|
||||
| {
|
||||
op: "app";
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
||||
value: T;
|
||||
after: OpID | "start";
|
||||
};
|
||||
|
||||
@@ -25,11 +25,11 @@ type DeletionOpPayload = {
|
||||
insertion: OpID;
|
||||
};
|
||||
|
||||
export type ListOpPayload<T extends JsonValue | CoValue> =
|
||||
export type ListOpPayload<T extends JsonValue> =
|
||||
| InsertionOpPayload<T>
|
||||
| DeletionOpPayload;
|
||||
|
||||
type InsertionEntry<T extends JsonValue | CoValue> = {
|
||||
type InsertionEntry<T extends JsonValue> = {
|
||||
madeAt: number;
|
||||
predecessors: OpID[];
|
||||
successors: OpID[];
|
||||
@@ -40,13 +40,16 @@ type DeletionEntry = {
|
||||
deletionID: OpID;
|
||||
} & DeletionOpPayload;
|
||||
|
||||
export class CoList<
|
||||
T extends JsonValue | CoValue,
|
||||
export class CoListView<
|
||||
Item extends JsonValue = JsonValue,
|
||||
Meta extends JsonObject | null = null
|
||||
> implements CoValue
|
||||
{
|
||||
/** @category 6. Meta */
|
||||
id: CoID<this>;
|
||||
/** @category 6. Meta */
|
||||
type = "colist" as const;
|
||||
/** @category 6. Meta */
|
||||
core: CoValueCore;
|
||||
/** @internal */
|
||||
afterStart: OpID[];
|
||||
@@ -56,7 +59,7 @@ export class CoList<
|
||||
insertions: {
|
||||
[sessionID: SessionID]: {
|
||||
[txIdx: number]: {
|
||||
[changeIdx: number]: InsertionEntry<T>;
|
||||
[changeIdx: number]: InsertionEntry<Item>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -68,6 +71,8 @@ export class CoList<
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @category 6. Meta */
|
||||
readonly _item!: Item;
|
||||
|
||||
/** @internal */
|
||||
constructor(core: CoValueCore) {
|
||||
@@ -78,19 +83,6 @@ export class CoList<
|
||||
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 = [];
|
||||
@@ -104,7 +96,7 @@ export class CoList<
|
||||
for (const [changeIdx, changeUntyped] of parseJSON(
|
||||
changes
|
||||
).entries()) {
|
||||
const change = changeUntyped as ListOpPayload<T>;
|
||||
const change = changeUntyped as ListOpPayload<Item>;
|
||||
|
||||
if (change.op === "pre" || change.op === "app") {
|
||||
let sessionEntry = this.insertions[txID.sessionID];
|
||||
@@ -204,10 +196,31 @@ export class CoList<
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the item currently at `idx`. */
|
||||
get(
|
||||
idx: number
|
||||
): (T extends CoValue ? CoID<T> : Exclude<T, CoValue>) | 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 | undefined {
|
||||
const entry = this.entries()[idx];
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
@@ -215,18 +228,23 @@ export class CoList<
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/** Returns the current items in the CoList as an array. */
|
||||
asArray(): (T extends CoValue ? CoID<T> : Exclude<T, CoValue>)[] {
|
||||
/**
|
||||
* Returns the current items in the CoList as an array.
|
||||
*
|
||||
* @category 1. Reading
|
||||
**/
|
||||
asArray(): Item[] {
|
||||
return this.entries().map((entry) => entry.value);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
entries(): {
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
||||
value: Item;
|
||||
madeAt: number;
|
||||
opID: OpID;
|
||||
}[] {
|
||||
const arr: {
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
||||
value: Item;
|
||||
madeAt: number;
|
||||
opID: OpID;
|
||||
}[] = [];
|
||||
@@ -243,7 +261,7 @@ export class CoList<
|
||||
private fillArrayFromOpID(
|
||||
opID: OpID,
|
||||
arr: {
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
||||
value: Item;
|
||||
madeAt: number;
|
||||
opID: OpID;
|
||||
}[]
|
||||
@@ -272,101 +290,115 @@ export class CoList<
|
||||
}
|
||||
}
|
||||
|
||||
/** 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[] {
|
||||
return this.asArray();
|
||||
}
|
||||
|
||||
/** @category 5. Edit history */
|
||||
editAt(idx: number):
|
||||
| {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item;
|
||||
}
|
||||
| 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 extends CoValue ? CoID<T> : Exclude<T, CoValue>)[] {
|
||||
return this.asArray();
|
||||
}
|
||||
|
||||
map<U>(
|
||||
mapper: (
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>,
|
||||
idx: number
|
||||
) => U
|
||||
): U[] {
|
||||
return this.entries().map((entry, idx) => mapper(entry.value, idx));
|
||||
}
|
||||
|
||||
filter<U extends T extends CoValue ? CoID<T> : Exclude<T, CoValue>>(
|
||||
predicate: (
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>,
|
||||
idx: number
|
||||
) => value is U
|
||||
): U[];
|
||||
filter(
|
||||
predicate: (
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>,
|
||||
idx: number
|
||||
) => boolean
|
||||
): (T extends CoValue ? CoID<T> : Exclude<T, CoValue>)[] {
|
||||
return this.entries()
|
||||
.filter((entry, idx) => predicate(entry.value, idx))
|
||||
.map((entry) => entry.value);
|
||||
}
|
||||
|
||||
reduce<U>(
|
||||
reducer: (
|
||||
accumulator: U,
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>,
|
||||
idx: number
|
||||
) => U,
|
||||
initialValue: U
|
||||
): U {
|
||||
return this.entries().reduce(
|
||||
(accumulator, entry, idx) => reducer(accumulator, entry.value, idx),
|
||||
initialValue
|
||||
);
|
||||
}
|
||||
|
||||
/** @category 3. Subscription */
|
||||
subscribe(listener: (coList: this) => void): () => void {
|
||||
return this.core.subscribe((content) => {
|
||||
listener(content as this);
|
||||
});
|
||||
}
|
||||
|
||||
edit(changer: (editable: WriteableCoList<T, Meta>) => void): this {
|
||||
const editable = new WriteableCoList<T, Meta>(this.core);
|
||||
changer(editable);
|
||||
return new CoList(this.core) as this;
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableCoList<
|
||||
T extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
export class CoList<
|
||||
Item extends JsonValue = JsonValue,
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
>
|
||||
extends CoList<T, Meta>
|
||||
extends CoListView<Item, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
/** @internal */
|
||||
edit(_changer: (editable: WriteableCoList<T, Meta>) => void): this {
|
||||
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 extends CoValue ? T | CoID<T> : T,
|
||||
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];
|
||||
@@ -384,47 +416,32 @@ export class WriteableCoList<
|
||||
[
|
||||
{
|
||||
op: "app",
|
||||
value: isCoValue(value) ? value.id : 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 extends CoValue ? T | CoID<T> : 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 extends CoValue ? T | CoID<T> : T,
|
||||
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];
|
||||
@@ -446,22 +463,25 @@ export class WriteableCoList<
|
||||
[
|
||||
{
|
||||
op: "pre",
|
||||
value: isCoValue(value) ? value.id : 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) {
|
||||
@@ -477,6 +497,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 = JsonValue,
|
||||
Meta extends JsonObject | null = JsonObject | 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,
|
||||
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,
|
||||
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,78 +1,69 @@
|
||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { TransactionID } from "../ids.js";
|
||||
import { AgentID, TransactionID } from "../ids.js";
|
||||
import { CoID, CoValue, isCoValue } from "../coValue.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { AccountID, isAccountID } from "../account.js";
|
||||
import { Group } from "../group.js";
|
||||
import { AccountID } from "./account.js";
|
||||
import { parseJSON } from "../jsonStringify.js";
|
||||
import { Group } from "./group.js";
|
||||
|
||||
type MapOp<K extends string, V extends JsonValue | CoValue | undefined> = {
|
||||
type MapOp<K extends string, V extends JsonValue | 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 | CoValue | undefined
|
||||
> =
|
||||
export type MapOpPayload<K extends string, V extends JsonValue | undefined> =
|
||||
| {
|
||||
op: "set";
|
||||
key: K;
|
||||
value: V extends CoValue ? CoID<V> : Exclude<V, CoValue>;
|
||||
value: V;
|
||||
}
|
||||
| {
|
||||
op: "del";
|
||||
key: K;
|
||||
};
|
||||
|
||||
/** A collaborative map with precise shape `M` and optional static metadata `Meta` */
|
||||
export class CoMap<
|
||||
M extends { [key: string]: JsonValue | CoValue | undefined },
|
||||
Meta extends JsonObject | null = null
|
||||
export class CoMapView<
|
||||
Shape extends { [key: string]: JsonValue | undefined } = {
|
||||
[key: string]: JsonValue | undefined;
|
||||
},
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
> implements CoValue
|
||||
{
|
||||
/** @category 6. Meta */
|
||||
id: CoID<this>;
|
||||
/** @category 6. Meta */
|
||||
type = "comap" as const;
|
||||
/** @category 6. Meta */
|
||||
core: CoValueCore;
|
||||
/** @internal */
|
||||
ops: {
|
||||
[Key in keyof M & string]?: MapOp<Key, M[Key]>[];
|
||||
[Key in keyof Shape & string]?: MapOp<Key, Shape[Key]>[];
|
||||
};
|
||||
/** @internal */
|
||||
atTimeFilter?: number = undefined;
|
||||
/** @category 6. Meta */
|
||||
readonly _shape!: Shape;
|
||||
|
||||
/** @internal */
|
||||
constructor(core: CoValueCore) {
|
||||
constructor(
|
||||
core: CoValueCore,
|
||||
options?: { ignorePrivateTransactions: true }
|
||||
) {
|
||||
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 { txID, changes, madeAt } of core.getValidSortedTransactions(
|
||||
options
|
||||
)) {
|
||||
for (const [changeIdx, changeUntyped] of parseJSON(
|
||||
changes
|
||||
).entries()) {
|
||||
const change = changeUntyped as MapOpPayload<
|
||||
keyof M & string,
|
||||
M[keyof M & string]
|
||||
keyof Shape & string,
|
||||
Shape[keyof Shape & string]
|
||||
>;
|
||||
let entries = this.ops[change.key];
|
||||
if (!entries) {
|
||||
@@ -84,222 +75,271 @@ export class CoMap<
|
||||
madeAt,
|
||||
changeIdx,
|
||||
...(change as MapOpPayload<
|
||||
keyof M & string,
|
||||
M[keyof M & string]
|
||||
keyof Shape & string,
|
||||
Shape[keyof Shape & string]
|
||||
>),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys(): (keyof M & string)[] {
|
||||
return Object.keys(this.ops) as (keyof M & string)[];
|
||||
/** @category 6. Meta */
|
||||
get meta(): Meta {
|
||||
return this.core.header.meta as Meta;
|
||||
}
|
||||
|
||||
/** Returns the current value for the given key. */
|
||||
get<K extends keyof M & string>(
|
||||
/** @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
|
||||
):
|
||||
| (M[K] extends CoValue ? CoID<M[K]> : Exclude<M[K], CoValue>)
|
||||
| undefined {
|
||||
const ops = this.ops[key];
|
||||
): 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<K extends (keyof Shape & string) = (keyof Shape & string)>(): K[] {
|
||||
const keys = Object.keys(this.ops) as K[];
|
||||
|
||||
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] | 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 keyof M & string>(
|
||||
key: K,
|
||||
time: number
|
||||
):
|
||||
| (M[K] extends CoValue ? CoID<M[K]> : Exclude<M[K], CoValue>)
|
||||
| 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 keyof M & string>(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 keyof M & string>(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 keyof M & string>(
|
||||
key: K
|
||||
):
|
||||
| {
|
||||
at: number;
|
||||
txID: TransactionID;
|
||||
value: M[K] extends CoValue ? CoID<M[K]> : Exclude<M[K], CoValue>;
|
||||
}
|
||||
| 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 keyof M & string>(
|
||||
key: K
|
||||
): {
|
||||
at: number;
|
||||
txID: TransactionID;
|
||||
value:
|
||||
| (M[K] extends CoValue ? CoID<M[K]> : Exclude<M[K], CoValue>)
|
||||
| undefined;
|
||||
}[] {
|
||||
const ops = this.ops[key];
|
||||
if (!ops) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const history: {
|
||||
at: number;
|
||||
txID: TransactionID;
|
||||
value:
|
||||
| (M[K] extends CoValue ? CoID<M[K]> : Exclude<M[K], CoValue>)
|
||||
| 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];
|
||||
} {
|
||||
const object: Partial<{
|
||||
[K in keyof Shape & string]: Shape[K];
|
||||
}> = {};
|
||||
|
||||
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];
|
||||
};
|
||||
}
|
||||
|
||||
/** @category 1. Reading */
|
||||
toJSON(): {
|
||||
[K in keyof Shape & string]: Shape[K];
|
||||
} {
|
||||
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];
|
||||
}
|
||||
| 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];
|
||||
}
|
||||
| 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 this);
|
||||
});
|
||||
}
|
||||
|
||||
edit(changer: (editable: WriteableCoMap<M, Meta>) => void): this {
|
||||
const editable = new WriteableCoMap<M, Meta>(this.core);
|
||||
changer(editable);
|
||||
return new CoMap(this.core) as this;
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableCoMap<
|
||||
M extends { [key: string]: JsonValue | CoValue | undefined },
|
||||
Meta extends JsonObject | null = null
|
||||
/** A collaborative map with precise shape `Shape` and optional static metadata `Meta` */
|
||||
export class CoMap<
|
||||
Shape extends { [key: string]: JsonValue | undefined } = {
|
||||
[key: string]: JsonValue | undefined;
|
||||
},
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
>
|
||||
extends CoMap<M, Meta>
|
||||
extends CoMapView<Shape, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
/** @internal */
|
||||
edit(_changer: (editable: WriteableCoMap<M, Meta>) => void): this {
|
||||
throw new Error("Already editing.");
|
||||
}
|
||||
|
||||
/** Sets a new value for the given key.
|
||||
/** 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. */
|
||||
set<K extends keyof M & string>(
|
||||
* 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: M[K] extends CoValue ? M[K] | CoID<M[K]> : M[K],
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
this.core.makeTransaction(
|
||||
[
|
||||
value: Shape[K],
|
||||
privacy?: "private" | "trusting"
|
||||
): this;
|
||||
set(
|
||||
kv: {
|
||||
[K in keyof Shape & string]?: Shape[K];
|
||||
},
|
||||
privacy?: "private" | "trusting"
|
||||
): this;
|
||||
set<K extends keyof Shape & string>(
|
||||
...args:
|
||||
| [
|
||||
{
|
||||
[K in keyof Shape & string]?: Shape[K];
|
||||
},
|
||||
("private" | "trusting")?
|
||||
]
|
||||
| [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 [
|
||||
{
|
||||
op: "set",
|
||||
key,
|
||||
value: isCoValue(value) ? value.id : value,
|
||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
||||
? Shape[K] | CoID<Shape[K]>
|
||||
: Shape[K];
|
||||
},
|
||||
],
|
||||
privacy
|
||||
);
|
||||
"private" | "trusting" | undefined
|
||||
];
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
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;
|
||||
}
|
||||
|
||||
/** Deletes the value for the given key (setting it to undefined).
|
||||
/** 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. */
|
||||
* 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 M & string,
|
||||
key: keyof Shape & string,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
): this {
|
||||
this.core.makeTransaction(
|
||||
[
|
||||
{
|
||||
@@ -310,6 +350,71 @@ export class WriteableCoMap<
|
||||
privacy
|
||||
);
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
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 (this.constructor as new (core: CoValueCore) => this)(
|
||||
this.core
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use `mutate` instead. */
|
||||
edit(mutator: (mutable: MutableCoMap<Shape, Meta>) => void): this {
|
||||
return this.mutate(mutator);
|
||||
}
|
||||
}
|
||||
|
||||
export class MutableCoMap<
|
||||
Shape extends { [key: string]: JsonValue | undefined } = {
|
||||
[key: string]: JsonValue | undefined;
|
||||
},
|
||||
Meta extends JsonObject | null = JsonObject | 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.
|
||||
*
|
||||
* @category 2. Mutation
|
||||
*/
|
||||
set<K extends keyof Shape & string>(
|
||||
key: K,
|
||||
value: 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.
|
||||
* @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,10 +1,10 @@
|
||||
import { JsonObject, JsonValue } from "../jsonValue.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 { Group } from "./group.js";
|
||||
import { AgentID, SessionID, TransactionID } from "../ids.js";
|
||||
import { base64URLtoBytes, bytesToBase64url } from "../base64url.js";
|
||||
import { AccountID, isAccountID } from "../account.js";
|
||||
import { AccountID, isAccountID } from "./account.js";
|
||||
import { parseJSON } from "../jsonStringify.js";
|
||||
|
||||
export type BinaryStreamInfo = {
|
||||
@@ -33,22 +33,24 @@ export type BinaryStreamItem =
|
||||
| BinaryStreamChunk
|
||||
| BinaryStreamEnd;
|
||||
|
||||
export type CoStreamItem<T extends JsonValue | CoValue> = {
|
||||
item: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
||||
export type CoStreamItem<Item extends JsonValue> = {
|
||||
value: Item;
|
||||
tx: TransactionID;
|
||||
madeAt: number;
|
||||
};
|
||||
|
||||
export class CoStream<
|
||||
T extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
export class CoStreamView<
|
||||
Item extends JsonValue = JsonValue,
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
> implements CoValue
|
||||
{
|
||||
id: CoID<this>;
|
||||
type = "costream" as const;
|
||||
core: CoValueCore;
|
||||
items: {
|
||||
[key: SessionID]: CoStreamItem<T>[];
|
||||
[key: SessionID]: CoStreamItem<Item>[];
|
||||
};
|
||||
readonly _item!: Item;
|
||||
|
||||
constructor(core: CoValueCore) {
|
||||
this.id = core.id as CoID<this>;
|
||||
@@ -65,6 +67,11 @@ 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 = {};
|
||||
@@ -75,21 +82,19 @@ export class CoStream<
|
||||
changes,
|
||||
} of this.core.getValidSortedTransactions()) {
|
||||
for (const changeUntyped of parseJSON(changes)) {
|
||||
const change = changeUntyped as T extends CoValue
|
||||
? CoID<T>
|
||||
: Exclude<T, CoValue>;
|
||||
const change = changeUntyped as Item;
|
||||
let entries = this.items[txID.sessionID];
|
||||
if (!entries) {
|
||||
entries = [];
|
||||
this.items[txID.sessionID] = entries;
|
||||
}
|
||||
entries.push({ item: change, madeAt });
|
||||
entries.push({ value: change, madeAt, tx: txID });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSingleStream():
|
||||
| (T extends CoValue ? CoID<T> : Exclude<T, CoValue>)[]
|
||||
| (Item)[]
|
||||
| undefined {
|
||||
if (Object.keys(this.items).length === 0) {
|
||||
return undefined;
|
||||
@@ -99,78 +104,130 @@ export class CoStream<
|
||||
);
|
||||
}
|
||||
|
||||
return Object.values(this.items)[0]?.map((item) => item.item);
|
||||
return Object.values(this.items)[0]?.map((item) => item.value);
|
||||
}
|
||||
|
||||
getLastItemsPerAccount(): {
|
||||
[account: AccountID]:
|
||||
| (T extends CoValue ? CoID<T> : Exclude<T, CoValue>)
|
||||
| undefined;
|
||||
} {
|
||||
const result: { [account: AccountID]: CoStreamItem<T> | undefined } =
|
||||
{};
|
||||
|
||||
for (const [sessionID, items] of Object.entries(this.items)) {
|
||||
const account = accountOrAgentIDfromSessionID(
|
||||
sessionID as SessionID
|
||||
);
|
||||
if (!isAccountID(account)) continue;
|
||||
if (items.length > 0) {
|
||||
const lastItemOfSession = items[items.length - 1]!;
|
||||
if (
|
||||
!result[account] ||
|
||||
lastItemOfSession.madeAt > result[account]!.madeAt
|
||||
) {
|
||||
result[account] = lastItemOfSession;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(result).map(([account, item]) => [
|
||||
account,
|
||||
item?.item,
|
||||
])
|
||||
);
|
||||
sessions(): SessionID[] {
|
||||
return Object.keys(this.items) as SessionID[];
|
||||
}
|
||||
|
||||
getLastItemFrom(
|
||||
account: AccountID
|
||||
): (T extends CoValue ? CoID<T> : Exclude<T, CoValue>) | undefined {
|
||||
let lastItem: CoStreamItem<T> | undefined;
|
||||
|
||||
for (const [sessionID, items] of Object.entries(this.items)) {
|
||||
if (sessionID.startsWith(account)) {
|
||||
if (items.length > 0) {
|
||||
const lastItemOfSession = items[items.length - 1]!;
|
||||
if (
|
||||
!lastItem ||
|
||||
lastItemOfSession.madeAt > lastItem.madeAt
|
||||
) {
|
||||
lastItem = lastItemOfSession;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastItem?.item;
|
||||
accounts(): Set<AccountID> {
|
||||
return new Set(this.sessions().map(accountOrAgentIDfromSessionID).filter(isAccountID));
|
||||
}
|
||||
|
||||
getLastItemFromMe():
|
||||
| (T extends CoValue ? CoID<T> : Exclude<T, CoValue>)
|
||||
nthItemIn(
|
||||
sessionID: SessionID,
|
||||
n: number
|
||||
):
|
||||
| {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item;
|
||||
}
|
||||
| undefined {
|
||||
const myAccountID = this.core.node.account.id;
|
||||
if (!isAccountID(myAccountID)) return undefined;
|
||||
return this.getLastItemFrom(myAccountID);
|
||||
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;
|
||||
}
|
||||
| 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 as Item,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
lastItemBy(account: AccountID | AgentID):
|
||||
| {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item;
|
||||
}
|
||||
| undefined {
|
||||
let latestItem:
|
||||
| {
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item;
|
||||
}
|
||||
| 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 extends CoValue ? CoID<T> : Exclude<T, CoValue>)[];
|
||||
[key: SessionID]: (Item )[];
|
||||
} {
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.items).map(([sessionID, items]) => [
|
||||
sessionID,
|
||||
items.map((item) => item.item),
|
||||
items.map((item) => item.value),
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -180,20 +237,57 @@ export class CoStream<
|
||||
listener(content as this);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
edit(changer: (editable: WriteableCoStream<T, Meta>) => void): this {
|
||||
const editable = new WriteableCoStream<T, Meta>(this.core);
|
||||
changer(editable);
|
||||
export class CoStream<
|
||||
Item extends JsonValue = JsonValue,
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
>
|
||||
extends CoStreamView<Item, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
push(
|
||||
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,
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
>
|
||||
extends CoStreamView<Item, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
push(
|
||||
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>
|
||||
extends CoStreamView<BinaryStreamItem, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
id!: CoID<this>;
|
||||
@@ -256,52 +350,85 @@ export class BinaryCoStream<
|
||||
finished,
|
||||
};
|
||||
}
|
||||
|
||||
edit(changer: (editable: WriteableBinaryCoStream<Meta>) => void): this {
|
||||
const editable = new WriteableBinaryCoStream<Meta>(this.core);
|
||||
changer(editable);
|
||||
return new BinaryCoStream(this.core) as this;
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableCoStream<
|
||||
T extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
>
|
||||
extends CoStream<T, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
/** @internal */
|
||||
edit(_changer: (editable: WriteableCoStream<T, Meta>) => void): this {
|
||||
throw new Error("Already editing.");
|
||||
}
|
||||
|
||||
push(
|
||||
item: T extends CoValue ? T | CoID<T> : T,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
) {
|
||||
this.core.makeTransaction(
|
||||
[isCoValue(item) ? item.id : item],
|
||||
privacy
|
||||
);
|
||||
this.fillFromCoValue();
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableBinaryCoStream<
|
||||
export class BinaryCoStream<
|
||||
Meta extends BinaryCoStreamMeta = { type: "binary" }
|
||||
>
|
||||
extends BinaryCoStream<Meta>
|
||||
extends BinaryCoStreamView<Meta>
|
||||
implements CoValue
|
||||
{
|
||||
/** @internal */
|
||||
edit(_changer: (editable: WriteableBinaryCoStream<Meta>) => void): this {
|
||||
throw new Error("Already editing.");
|
||||
push(
|
||||
item: BinaryStreamItem,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): this {
|
||||
this.core.makeTransaction([item], privacy);
|
||||
return new BinaryCoStream(this.core) as this;
|
||||
}
|
||||
|
||||
startBinaryStream(
|
||||
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") {
|
||||
WriteableCoStream.prototype.push.call(this, item, privacy);
|
||||
MutableCoStream.prototype.push.call(this, item, privacy);
|
||||
}
|
||||
|
||||
startBinaryStream(
|
||||
|
||||
381
packages/cojson/src/coValues/group.ts
Normal file
381
packages/cojson/src/coValues/group.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { CoID, CoValue, expectMap } from "../coValue.js";
|
||||
import { CoMap } from "./coMap.js";
|
||||
import { CoList } from "./coList.js";
|
||||
import { JsonObject } from "../jsonValue.js";
|
||||
import { BinaryCoStream, CoStream } from "./coStream.js";
|
||||
import {
|
||||
Encrypted,
|
||||
KeyID,
|
||||
KeySecret,
|
||||
createdNowUnique,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
encryptKeySecret,
|
||||
getAgentSealerID,
|
||||
Sealed,
|
||||
newRandomSecretSeed,
|
||||
agentSecretFromSecretSeed,
|
||||
getAgentID,
|
||||
} from "../crypto.js";
|
||||
import { AgentID, isAgentID } from "../ids.js";
|
||||
import { AccountID, Profile } from "./account.js";
|
||||
import { Role } from "../permissions.js";
|
||||
import { base58 } from "@scure/base";
|
||||
|
||||
export const EVERYONE = "everyone" as const;
|
||||
export type Everyone = "everyone";
|
||||
|
||||
export type GroupShape<P extends Profile, R extends CoMap> = {
|
||||
profile?: CoID<P> | null;
|
||||
root?: CoID<R> | null;
|
||||
[key: AccountID | AgentID]: Role;
|
||||
[EVERYONE]?: Role;
|
||||
readKey?: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountID | AgentID}`]: Sealed<KeySecret>;
|
||||
[revelationFor: `${KeyID}_for_${Everyone}`]: KeySecret;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
};
|
||||
|
||||
export function expectGroup(content: CoValue): Group {
|
||||
const map = expectMap(content);
|
||||
if (map.core.header.ruleset.type !== "group") {
|
||||
throw new Error("Expected group ruleset in group");
|
||||
}
|
||||
|
||||
if (!(map instanceof Group)) {
|
||||
throw new Error("Expected group");
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
|
||||
*
|
||||
* A `Group` object exposes methods for permission management and allows you to create new CoValues owned by that group.
|
||||
*
|
||||
* (Internally, a `Group` is also just a `CoMap`, mapping member accounts to roles and containing some
|
||||
* state management for making cryptographic keys available to current members)
|
||||
*
|
||||
* @example
|
||||
* You typically get a group from a CoValue that you already have loaded:
|
||||
*
|
||||
* ```typescript
|
||||
* const group = coMap.group;
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Or, you can create a new group with a `LocalNode`:
|
||||
*
|
||||
* ```typescript
|
||||
* const localNode.createGroup();
|
||||
* ```
|
||||
* */
|
||||
export class Group<
|
||||
P extends Profile = Profile,
|
||||
R extends CoMap = CoMap,
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
> extends CoMap<GroupShape<P, R>, Meta> {
|
||||
/**
|
||||
* Returns the current role of a given account.
|
||||
*
|
||||
* @category 1. Role reading
|
||||
*/
|
||||
roleOf(accountID: AccountID): Role | undefined {
|
||||
return this.roleOfInternal(accountID);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
roleOfInternal(accountID: AccountID | AgentID): Role | undefined {
|
||||
return this.get(accountID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the role of the current account in the group.
|
||||
*
|
||||
* @category 1. Role reading
|
||||
*/
|
||||
myRole(): Role | undefined {
|
||||
return this.roleOfInternal(this.core.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.
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
addMember(accountID: AccountID | Everyone, role: Role): this {
|
||||
return this.addMemberInternal(accountID, role);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
addMemberInternal(
|
||||
accountID: AccountID | AgentID | Everyone,
|
||||
role: Role
|
||||
): this {
|
||||
return this.mutate((mutable) => {
|
||||
const currentReadKey = this.core.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
if (accountID === EVERYONE) {
|
||||
if (!(role === "reader" || role === "writer")) {
|
||||
throw new Error(
|
||||
"Can't make everyone something other than reader or writer"
|
||||
);
|
||||
}
|
||||
mutable.set(accountID, role, "trusting");
|
||||
|
||||
if (mutable.get(accountID) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
}
|
||||
|
||||
mutable.set(
|
||||
`${currentReadKey.id}_for_${EVERYONE}`,
|
||||
currentReadKey.secret,
|
||||
"trusting"
|
||||
);
|
||||
} else {
|
||||
const agent = this.core.node.resolveAccountAgent(
|
||||
accountID,
|
||||
"Expected to know agent to add them to group"
|
||||
);
|
||||
mutable.set(accountID, role, "trusting");
|
||||
|
||||
if (mutable.get(accountID) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
}
|
||||
|
||||
mutable.set(
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal({
|
||||
message: currentReadKey.secret,
|
||||
from: this.core.node.account.currentSealerSecret(),
|
||||
to: getAgentSealerID(agent),
|
||||
nOnceMaterial: {
|
||||
in: this.id,
|
||||
tx: this.core.nextTransactionID(),
|
||||
},
|
||||
}),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
rotateReadKey(): this {
|
||||
const currentlyPermittedReaders = this.keys().filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.get(key);
|
||||
return (
|
||||
role === "admin" || role === "writer" || role === "reader"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as (AccountID | AgentID)[];
|
||||
|
||||
const maybeCurrentReadKey = this.core.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error(
|
||||
"Can't rotate read key secret we don't have access to"
|
||||
);
|
||||
}
|
||||
|
||||
const currentReadKey = {
|
||||
id: maybeCurrentReadKey.id,
|
||||
secret: maybeCurrentReadKey.secret,
|
||||
};
|
||||
|
||||
const newReadKey = newRandomKeySecret();
|
||||
|
||||
return this.mutate((mutable) => {
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.core.node.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader"
|
||||
);
|
||||
|
||||
mutable.set(
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
seal({
|
||||
message: newReadKey.secret,
|
||||
from: this.core.node.account.currentSealerSecret(),
|
||||
to: getAgentSealerID(reader),
|
||||
nOnceMaterial: {
|
||||
in: this.id,
|
||||
tx: this.core.nextTransactionID(),
|
||||
},
|
||||
}),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
|
||||
mutable.set(
|
||||
`${currentReadKey.id}_for_${newReadKey.id}`,
|
||||
encryptKeySecret({
|
||||
encrypting: newReadKey,
|
||||
toEncrypt: currentReadKey,
|
||||
}).encrypted,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
mutable.set("readKey", newReadKey.id, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
removeMember(accountID: AccountID): this {
|
||||
return this.removeMemberInternal(accountID);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
removeMemberInternal(accountID: AccountID | AgentID): this {
|
||||
const afterRevoke = this.mutate((map) => {
|
||||
map.set(accountID, "revoked", "trusting");
|
||||
});
|
||||
|
||||
return afterRevoke.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.
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
|
||||
const secretSeed = newRandomSecretSeed();
|
||||
|
||||
const inviteSecret = agentSecretFromSecretSeed(secretSeed);
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
this.addMemberInternal(inviteID, `${role}Invite` as Role);
|
||||
|
||||
return inviteSecretFromSecretSeed(secretSeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 CoMap>(
|
||||
init?: M["_shape"],
|
||||
meta?: M["meta"],
|
||||
initPrivacy: "trusting" | "private" = "private"
|
||||
): M {
|
||||
let map = this.core.node
|
||||
.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...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.
|
||||
*
|
||||
* @category 3. Value creation
|
||||
*/
|
||||
createList<L extends CoList>(
|
||||
init?: L["_item"][],
|
||||
meta?: L["meta"],
|
||||
initPrivacy: "trusting" | "private" = "private"
|
||||
): L {
|
||||
let list = this.core.node
|
||||
.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as L;
|
||||
|
||||
if (init) {
|
||||
for (const item of init) {
|
||||
list = list.append(item, undefined, initPrivacy);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/** @category 3. Value creation */
|
||||
createStream<C extends CoStream>(meta?: C["meta"]): C {
|
||||
return this.core.node
|
||||
.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as C;
|
||||
}
|
||||
|
||||
/** @category 3. Value creation */
|
||||
createBinaryStream<C extends BinaryCoStream>(
|
||||
meta: C["meta"] = { type: "binary" }
|
||||
): C {
|
||||
return this.core.node
|
||||
.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as C;
|
||||
}
|
||||
}
|
||||
|
||||
export type InviteSecret = `inviteSecret_z${string}`;
|
||||
|
||||
function inviteSecretFromSecretSeed(secretSeed: Uint8Array): InviteSecret {
|
||||
return `inviteSecret_z${base58.encode(secretSeed)}`;
|
||||
}
|
||||
|
||||
export function secretSeedFromInviteSecret(inviteSecret: InviteSecret) {
|
||||
if (!inviteSecret.startsWith("inviteSecret_z")) {
|
||||
throw new Error("Invalid invite secret");
|
||||
}
|
||||
|
||||
return base58.decode(inviteSecret.slice("inviteSecret_z".length));
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { JsonObject } from '../jsonValue.js';
|
||||
import { CoID, CoValue } from '../coValue.js';
|
||||
import { CoValueCore } from '../coValueCore.js';
|
||||
import { Group } from '../index.js';
|
||||
|
||||
export class Static<T extends JsonObject> implements CoValue{
|
||||
id: CoID<this>;
|
||||
type = "static" as const;
|
||||
core: CoValueCore;
|
||||
|
||||
constructor(core: CoValueCore) {
|
||||
this.id = core.id as CoID<this>;
|
||||
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: (st: this) => void): () => void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
@@ -6,33 +6,43 @@ 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 { 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 => {
|
||||
blake3Instance = bl3;
|
||||
blake3HashOnce = (data) => {
|
||||
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');
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
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");
|
||||
};
|
||||
blake3incrementalUpdateSLOW_WITH_DEVTOOLS = (state, data) => {
|
||||
bl3.load(state).update(data);
|
||||
return bl3.save();
|
||||
};
|
||||
blake3digestForState = (state) => {
|
||||
return bl3.load(state).digest("binary");
|
||||
};
|
||||
resolve();
|
||||
})
|
||||
.catch((e) =>
|
||||
console.error("Failed to load cryptography dependencies", e)
|
||||
);
|
||||
});
|
||||
|
||||
export type SignerSecret = `signerSecret_z${string}`;
|
||||
@@ -149,12 +159,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 +235,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));
|
||||
}
|
||||
@@ -333,8 +351,7 @@ function decryptRaw<T extends JsonValue, N extends JsonValue>(
|
||||
);
|
||||
const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext);
|
||||
|
||||
return textDecoder.decode(plaintext) as Stringified<T>;
|
||||
|
||||
return textDecoder.decode(plaintext) as Stringified<T>;
|
||||
}
|
||||
|
||||
function decrypt<T extends JsonValue, N extends JsonValue>(
|
||||
@@ -345,7 +362,7 @@ function decrypt<T extends JsonValue, N extends JsonValue>(
|
||||
try {
|
||||
return parseJSON(decryptRaw(encrypted, keySecret, nOnceMaterial));
|
||||
} catch (e) {
|
||||
console.error("Decryption error", e)
|
||||
console.error("Decryption error", e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
import { CoID, CoValue, AnyCoValue } from "./coValue.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
Encrypted,
|
||||
KeyID,
|
||||
KeySecret,
|
||||
createdNowUnique,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
encryptKeySecret,
|
||||
getAgentSealerID,
|
||||
Sealed,
|
||||
newRandomSecretSeed,
|
||||
agentSecretFromSecretSeed,
|
||||
getAgentID,
|
||||
} from "./crypto.js";
|
||||
import { LocalNode } from "./node.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";
|
||||
|
||||
export type GroupContent = {
|
||||
profile: CoID<Profile> | null;
|
||||
[key: AccountID | AgentID]: Role;
|
||||
readKey: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountID | AgentID}`]: Sealed<KeySecret>;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
};
|
||||
|
||||
export function expectGroupContent(
|
||||
content: CoValue
|
||||
): CoMap<GroupContent, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<GroupContent, JsonObject | null>;
|
||||
}
|
||||
|
||||
/** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
|
||||
*
|
||||
* A `Group` object exposes methods for permission management and allows you to create new CoValues owned by that group.
|
||||
*
|
||||
* (Internally, a `Group` is also just a `CoMap`, mapping member accounts to roles and containing some
|
||||
* state management for making cryptographic keys available to current members)
|
||||
*
|
||||
* @example
|
||||
* You typically get a group from a CoValue that you already have loaded:
|
||||
*
|
||||
* ```typescript
|
||||
* const group = coMap.group;
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Or, you can create a new group with a `LocalNode`:
|
||||
*
|
||||
* ```typescript
|
||||
* const localNode.createGroup();
|
||||
* ```
|
||||
* */
|
||||
export class Group {
|
||||
underlyingMap: CoMap<GroupContent, JsonObject | null>;
|
||||
/** @internal */
|
||||
node: LocalNode;
|
||||
|
||||
/** @internal */
|
||||
constructor(
|
||||
underlyingMap: CoMap<GroupContent, JsonObject | null>,
|
||||
node: LocalNode
|
||||
) {
|
||||
this.underlyingMap = underlyingMap;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
/** Returns the `CoID` of the `Group`. */
|
||||
get id(): CoID<CoMap<GroupContent, JsonObject | null>> {
|
||||
return this.underlyingMap.id;
|
||||
}
|
||||
|
||||
/** Returns the current role of a given account. */
|
||||
roleOf(accountID: AccountID): Role | undefined {
|
||||
return this.roleOfInternal(accountID);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
roleOfInternal(accountID: AccountID | AgentID): Role | undefined {
|
||||
return this.underlyingMap.get(accountID);
|
||||
}
|
||||
|
||||
/** Returns the role of the current account in the group. */
|
||||
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. */
|
||||
addMember(accountID: AccountID, role: Role) {
|
||||
this.addMemberInternal(accountID, role);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
addMemberInternal(accountID: AccountID | AgentID, role: Role) {
|
||||
this.underlyingMap = this.underlyingMap.edit((map) => {
|
||||
const currentReadKey = this.underlyingMap.core.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
const agent = this.node.resolveAccountAgent(
|
||||
accountID,
|
||||
"Expected to know agent to add them to group"
|
||||
);
|
||||
|
||||
map.set(accountID, role, "trusting");
|
||||
|
||||
if (map.get(accountID) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal(
|
||||
currentReadKey.secret,
|
||||
this.underlyingMap.core.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(agent),
|
||||
{
|
||||
in: this.underlyingMap.core.id,
|
||||
tx: this.underlyingMap.core.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
rotateReadKey() {
|
||||
const currentlyPermittedReaders = this.underlyingMap
|
||||
.keys()
|
||||
.filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.underlyingMap.get(key);
|
||||
return (
|
||||
role === "admin" ||
|
||||
role === "writer" ||
|
||||
role === "reader"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as (AccountID | AgentID)[];
|
||||
|
||||
const maybeCurrentReadKey = this.underlyingMap.core.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error(
|
||||
"Can't rotate read key secret we don't have access to"
|
||||
);
|
||||
}
|
||||
|
||||
const currentReadKey = {
|
||||
id: maybeCurrentReadKey.id,
|
||||
secret: maybeCurrentReadKey.secret,
|
||||
};
|
||||
|
||||
const newReadKey = newRandomKeySecret();
|
||||
|
||||
this.underlyingMap = this.underlyingMap.edit((map) => {
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.node.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader"
|
||||
);
|
||||
|
||||
map.set(
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
seal(
|
||||
newReadKey.secret,
|
||||
this.underlyingMap.core.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(reader),
|
||||
{
|
||||
in: this.underlyingMap.core.id,
|
||||
tx: this.underlyingMap.core.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${newReadKey.id}`,
|
||||
encryptKeySecret({
|
||||
encrypting: newReadKey,
|
||||
toEncrypt: currentReadKey,
|
||||
}).encrypted,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
map.set("readKey", newReadKey.id, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
/** 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) */
|
||||
removeMember(accountID: AccountID) {
|
||||
this.removeMemberInternal(accountID);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
removeMemberInternal(accountID: AccountID | AgentID) {
|
||||
this.underlyingMap = this.underlyingMap.edit((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. */
|
||||
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
|
||||
const secretSeed = newRandomSecretSeed();
|
||||
|
||||
const inviteSecret = agentSecretFromSecretSeed(secretSeed);
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
this.addMemberInternal(inviteID, `${role}Invite` as Role);
|
||||
|
||||
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 | AnyCoValue | undefined },
|
||||
JsonObject | null
|
||||
>
|
||||
>(
|
||||
init?: M extends CoMap<infer M, infer _Meta>
|
||||
? {
|
||||
[K in keyof M]: M[K] extends AnyCoValue
|
||||
? M[K] | CoID<M[K]>
|
||||
: M[K];
|
||||
}
|
||||
: never,
|
||||
meta?: M["meta"]
|
||||
): M {
|
||||
let map = this.node
|
||||
.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.underlyingMap.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as M;
|
||||
|
||||
if (init) {
|
||||
map = map.edit((editable) => {
|
||||
for (const [key, value] of Object.entries(init)) {
|
||||
editable.set(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 | CoValue, JsonObject | null>>(
|
||||
init?: L extends CoList<infer I, infer _Meta>
|
||||
? (I extends CoValue ? CoID<I> | I : I)[]
|
||||
: never,
|
||||
meta?: L["meta"]
|
||||
): L {
|
||||
let list = this.node
|
||||
.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.underlyingMap.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as L;
|
||||
|
||||
if (init) {
|
||||
list = list.edit((editable) => {
|
||||
for (const item of init) {
|
||||
editable.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
createStream<C extends CoStream<JsonValue | CoValue, JsonObject | null>>(
|
||||
meta?: C["meta"]
|
||||
): C {
|
||||
return this.node
|
||||
.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.underlyingMap.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as C;
|
||||
}
|
||||
|
||||
createBinaryStream<C extends BinaryCoStream<BinaryCoStreamMeta>>(
|
||||
meta: C["meta"] = { type: "binary" }
|
||||
): C {
|
||||
return this.node
|
||||
.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.underlyingMap.id,
|
||||
},
|
||||
meta: meta,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as C;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
sessionId: SessionID
|
||||
): Group {
|
||||
return new Group(
|
||||
expectGroupContent(
|
||||
this.underlyingMap.core
|
||||
.testWithDifferentAccount(account, sessionId)
|
||||
.getCurrentContent()
|
||||
),
|
||||
this.node
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type InviteSecret = `inviteSecret_z${string}`;
|
||||
|
||||
function inviteSecretFromSecretSeed(secretSeed: Uint8Array): InviteSecret {
|
||||
return `inviteSecret_z${base58.encode(secretSeed)}`;
|
||||
}
|
||||
|
||||
export function secretSeedFromInviteSecret(inviteSecret: InviteSecret) {
|
||||
if (!inviteSecret.startsWith("inviteSecret_z")) {
|
||||
throw new Error("Invalid invite secret");
|
||||
}
|
||||
|
||||
return base58.decode(inviteSecret.slice("inviteSecret_z".length));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AccountID } from './account.js';
|
||||
import { AccountID } from './coValues/account.js';
|
||||
import { base58 } from "@scure/base";
|
||||
import { shortHashLength } from './crypto.js';
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { CoValueCore, newRandomSessionID, MAX_RECOMMENDED_TX_SIZE } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import {
|
||||
CoValueCore,
|
||||
newRandomSessionID,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
accountOrAgentIDfromSessionID
|
||||
} from "./coValueCore.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import type { CoValue } from "./coValue.js";
|
||||
import { CoMap, WriteableCoMap } from "./coValues/coMap.js";
|
||||
import { CoList, WriteableCoList } from "./coValues/coList.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 +23,36 @@ import {
|
||||
agentSecretFromSecretSeed,
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
cryptoReady
|
||||
cryptoReady,
|
||||
} from "./crypto.js";
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
|
||||
import {
|
||||
AnonymousControlledAccount,
|
||||
ControlledAccount,
|
||||
} from "./coValues/account.js";
|
||||
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { Group, expectGroup, EVERYONE } from "./coValues/group.js";
|
||||
import { base64URLtoBytes, bytesToBase64url } from "./base64url.js";
|
||||
import { parseJSON } from "./jsonStringify.js";
|
||||
import { Account, Profile, isAccountID } from "./coValues/account.js";
|
||||
|
||||
import type { SessionID, AgentID } from "./ids.js";
|
||||
import type { CoID, AnyCoValue } from "./coValue.js";
|
||||
import type { Queried } from "./queries.js";
|
||||
import type { BinaryStreamInfo, BinaryCoStreamMeta } from "./coValues/coStream.js";
|
||||
import type { Queried, QueryExtension } 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 { QueriedAccount } from "./queriedCoValues/queriedAccount.js";
|
||||
import { QueriedGroup } from "./queriedCoValues/queriedGroup.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, Account, Profile } from "./account.js";
|
||||
import type { InviteSecret } from "./group.js";
|
||||
import type { AccountID, AccountMeta, AccountMigration, ProfileMeta } from "./coValues/account.js";
|
||||
import type { InviteSecret } from "./coValues/group.js";
|
||||
import type * as Media from "./media.js";
|
||||
|
||||
type Value = JsonValue | AnyCoValue;
|
||||
@@ -54,41 +71,50 @@ export const cojsonInternals = {
|
||||
agentSecretFromSecretSeed,
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
expectGroupContent,
|
||||
expectGroup,
|
||||
base64URLtoBytes,
|
||||
bytesToBase64url,
|
||||
parseJSON
|
||||
parseJSON,
|
||||
accountOrAgentIDfromSessionID,
|
||||
isAccountID,
|
||||
};
|
||||
|
||||
export {
|
||||
LocalNode,
|
||||
Group,
|
||||
EVERYONE,
|
||||
CoMap,
|
||||
WriteableCoMap,
|
||||
MutableCoMap,
|
||||
CoList,
|
||||
WriteableCoList,
|
||||
MutableCoList,
|
||||
CoStream,
|
||||
WriteableCoStream,
|
||||
MutableCoStream,
|
||||
BinaryCoStream,
|
||||
WriteableBinaryCoStream,
|
||||
MutableBinaryCoStream,
|
||||
CoValue,
|
||||
CoID,
|
||||
AnyCoValue,
|
||||
Queried,
|
||||
QueriedCoMap,
|
||||
QueriedCoList,
|
||||
QueriedCoStream,
|
||||
QueriedGroup,
|
||||
QueriedAccount,
|
||||
Account,
|
||||
AccountID,
|
||||
AccountMeta,
|
||||
AccountMigration,
|
||||
Profile,
|
||||
ProfileMeta,
|
||||
SessionID,
|
||||
Media,
|
||||
CoValueCore,
|
||||
AnonymousControlledAccount,
|
||||
ControlledAccount,
|
||||
cryptoReady as cojsonReady,
|
||||
MAX_RECOMMENDED_TX_SIZE
|
||||
};
|
||||
|
||||
export type {
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
Value,
|
||||
JsonValue,
|
||||
CoValue,
|
||||
AnyCoValue,
|
||||
CoID,
|
||||
Queried,
|
||||
AccountID,
|
||||
Account,
|
||||
Profile,
|
||||
SessionID,
|
||||
Peer,
|
||||
BinaryStreamInfo,
|
||||
BinaryCoStreamMeta,
|
||||
@@ -96,7 +122,7 @@ export type {
|
||||
AgentSecret,
|
||||
InviteSecret,
|
||||
SyncMessage,
|
||||
Media
|
||||
QueryExtension,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
@@ -110,9 +136,8 @@ export namespace CojsonInternalTypes {
|
||||
export type Transaction = import("./coValueCore.js").Transaction;
|
||||
export type Signature = import("./crypto.js").Signature;
|
||||
export type RawCoID = import("./ids.js").RawCoID;
|
||||
export type AccountContent = import("./account.js").AccountContent;
|
||||
export type ProfileContent = import("./account.js").ProfileContent;
|
||||
export type ProfileMeta = import("./account.js").ProfileMeta;
|
||||
export type ProfileShape = import("./coValues/account.js").ProfileShape;
|
||||
export type ProfileMeta = import("./coValues/account.js").ProfileMeta;
|
||||
export type SealerSecret = import("./crypto.js").SealerSecret;
|
||||
export type SignerSecret = import("./crypto.js").SignerSecret;
|
||||
}
|
||||
|
||||
@@ -17,16 +17,16 @@ import {
|
||||
import {
|
||||
InviteSecret,
|
||||
Group,
|
||||
GroupContent,
|
||||
expectGroupContent,
|
||||
GroupShape,
|
||||
expectGroup,
|
||||
secretSeedFromInviteSecret,
|
||||
} from "./group.js";
|
||||
} from "./coValues/group.js";
|
||||
import { Peer, SyncManager } from "./sync.js";
|
||||
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { Queried, query } from "./queries.js";
|
||||
import {
|
||||
AccountGroup,
|
||||
Account,
|
||||
AccountMeta,
|
||||
accountHeaderForInitialAgentSecret,
|
||||
GeneralizedControlledAccount,
|
||||
@@ -34,11 +34,12 @@ import {
|
||||
AnonymousControlledAccount,
|
||||
AccountID,
|
||||
Profile,
|
||||
AccountContent,
|
||||
Account,
|
||||
} from "./account.js";
|
||||
isAccountID,
|
||||
AccountMigration,
|
||||
} from "./coValues/account.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { CoValue } from "./index.js";
|
||||
import { QueriedAccount } from "./queriedCoValues/queriedAccount.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).
|
||||
|
||||
@@ -54,11 +55,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
|
||||
@@ -67,10 +71,20 @@ export class LocalNode {
|
||||
this.currentSessionID = currentSessionID;
|
||||
}
|
||||
|
||||
static withNewlyCreatedAccount(
|
||||
name: string,
|
||||
initialAgentSecret = newRandomAgentSecret()
|
||||
): {
|
||||
/** @category 2. Node Creation */
|
||||
static withNewlyCreatedAccount<
|
||||
P extends Profile = Profile,
|
||||
R extends CoMap = CoMap,
|
||||
Meta extends AccountMeta = AccountMeta
|
||||
>({
|
||||
name,
|
||||
migration,
|
||||
initialAgentSecret = newRandomAgentSecret(),
|
||||
}: {
|
||||
name: string;
|
||||
migration?: AccountMigration<P, R, Meta>;
|
||||
initialAgentSecret?: AgentSecret;
|
||||
}): {
|
||||
node: LocalNode;
|
||||
accountID: AccountID;
|
||||
accountSecret: AgentSecret;
|
||||
@@ -84,25 +98,52 @@ export class LocalNode {
|
||||
|
||||
const account = setupNode.createAccount(name, initialAgentSecret);
|
||||
|
||||
const nodeWithAccount = account.node.testWithDifferentAccount(
|
||||
const nodeWithAccount = account.core.node.testWithDifferentAccount(
|
||||
account,
|
||||
newRandomSessionID(account.id)
|
||||
);
|
||||
|
||||
const accountOnNodeWithAccount = nodeWithAccount.account as ControlledAccount<P, R, Meta>;
|
||||
|
||||
const profile = nodeWithAccount.expectProfileLoaded(
|
||||
accountOnNodeWithAccount.id,
|
||||
"After creating account"
|
||||
);
|
||||
|
||||
if (migration) {
|
||||
migration(accountOnNodeWithAccount, profile as P);
|
||||
nodeWithAccount.account = new ControlledAccount(
|
||||
accountOnNodeWithAccount.core,
|
||||
accountOnNodeWithAccount.agentSecret
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
node: nodeWithAccount,
|
||||
accountID: account.id,
|
||||
accountSecret: account.agentSecret,
|
||||
accountID: accountOnNodeWithAccount.id,
|
||||
accountSecret: accountOnNodeWithAccount.agentSecret,
|
||||
sessionID: nodeWithAccount.currentSessionID,
|
||||
};
|
||||
}
|
||||
|
||||
static async withLoadedAccount(
|
||||
accountID: AccountID,
|
||||
accountSecret: AgentSecret,
|
||||
sessionID: SessionID,
|
||||
peersToLoadFrom: Peer[]
|
||||
): Promise<LocalNode> {
|
||||
/** @category 2. Node Creation */
|
||||
static async withLoadedAccount<
|
||||
P extends Profile = Profile,
|
||||
R extends CoMap = CoMap,
|
||||
Meta extends AccountMeta = AccountMeta
|
||||
>({
|
||||
accountID,
|
||||
accountSecret,
|
||||
sessionID,
|
||||
peersToLoadFrom,
|
||||
migration,
|
||||
}: {
|
||||
accountID: AccountID;
|
||||
accountSecret: AgentSecret;
|
||||
sessionID: SessionID;
|
||||
peersToLoadFrom: Peer[];
|
||||
migration?: AccountMigration<P, R, Meta>;
|
||||
}): Promise<LocalNode> {
|
||||
const loadingNode = new LocalNode(
|
||||
new AnonymousControlledAccount(accountSecret),
|
||||
newRandomSessionID(accountID)
|
||||
@@ -111,18 +152,41 @@ 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;
|
||||
const controlledAccount = new ControlledAccount(
|
||||
account.core,
|
||||
accountSecret
|
||||
);
|
||||
|
||||
// since this is all synchronous, we can just swap out nodes for the SyncManager
|
||||
const node = loadingNode.testWithDifferentAccount(
|
||||
new ControlledAccount(accountSecret, account, loadingNode),
|
||||
controlledAccount,
|
||||
sessionID
|
||||
);
|
||||
node.sync = loadingNode.sync;
|
||||
node.sync.local = node;
|
||||
node.syncManager = loadingNode.syncManager;
|
||||
node.syncManager.local = node;
|
||||
|
||||
controlledAccount.core.node = node;
|
||||
|
||||
const profileID = account.get("profile");
|
||||
if (!profileID) {
|
||||
throw new Error("Account has no profile");
|
||||
}
|
||||
const profile = await node.load(profileID);
|
||||
|
||||
if (migration) {
|
||||
migration(
|
||||
controlledAccount as ControlledAccount<P, R, Meta>,
|
||||
profile as P
|
||||
);
|
||||
node.account = new ControlledAccount(
|
||||
controlledAccount.core,
|
||||
controlledAccount.agentSecret
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
@@ -132,7 +196,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;
|
||||
}
|
||||
@@ -145,7 +209,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);
|
||||
@@ -157,56 +221,83 @@ 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 CoValue>(id: CoID<T>): Promise<T> {
|
||||
return (await this.loadCoValue(id)).getCurrentContent() as T;
|
||||
}
|
||||
|
||||
subscribe<T extends CoValue>(id: CoID<T>, callback: (update: T) => void): () => void {
|
||||
/** @category 3. Low-level */
|
||||
subscribe<T extends CoValue>(
|
||||
id: CoID<T>,
|
||||
callback: (update: T) => void
|
||||
): () => void {
|
||||
let stopped = false;
|
||||
let unsubscribe!: () => void;
|
||||
|
||||
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);
|
||||
});
|
||||
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?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** @category 1. High-level */
|
||||
|
||||
query<T extends CoValue>(
|
||||
id: CoID<T>,
|
||||
callback: (update: Queried<T> | undefined) => void
|
||||
): () => void;
|
||||
query<
|
||||
P extends Profile = Profile,
|
||||
R extends CoMap = CoMap,
|
||||
Meta extends AccountMeta = AccountMeta
|
||||
>(
|
||||
id: "me",
|
||||
callback: (
|
||||
update: QueriedAccount<Account<P, R, Meta>> | undefined
|
||||
) => void
|
||||
): () => void;
|
||||
query(
|
||||
id: CoID<CoValue> | "me",
|
||||
callback: (
|
||||
update: Queried<CoValue> | QueriedAccount | undefined
|
||||
) => void
|
||||
): () => void;
|
||||
query(
|
||||
id: CoID<CoValue> | "me",
|
||||
callback: (
|
||||
// TODO: sort this out
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
update: any
|
||||
) => void
|
||||
): () => void {
|
||||
return query(id, this, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Account>(id);
|
||||
const profileID = account.get("profile");
|
||||
|
||||
if (!profileID) {
|
||||
throw new Error(`Account ${id} has no profile`);
|
||||
if (id === "me") {
|
||||
const meId = this.account.id;
|
||||
if (!isAccountID(meId)) {
|
||||
throw new Error("Can only query 'me' for accounts");
|
||||
}
|
||||
return query(meId, this, callback);
|
||||
} else {
|
||||
return query(id, this, callback);
|
||||
}
|
||||
return (
|
||||
await this.loadCoValue(profileID)
|
||||
).getCurrentContent() as Profile;
|
||||
}
|
||||
|
||||
/** @deprecated Use Account.acceptInvite instead */
|
||||
async acceptInvite<T extends CoValue>(
|
||||
groupOrOwnedValueID: CoID<T>,
|
||||
inviteSecret: InviteSecret
|
||||
@@ -215,16 +306,14 @@ export class LocalNode {
|
||||
|
||||
if (groupOrOwnedValue.core.header.ruleset.type === "ownedByGroup") {
|
||||
return this.acceptInvite(
|
||||
groupOrOwnedValue.core.header.ruleset.group as CoID<
|
||||
CoMap<GroupContent>
|
||||
>,
|
||||
groupOrOwnedValue.core.header.ruleset.group as CoID<Group>,
|
||||
inviteSecret
|
||||
);
|
||||
} else if (groupOrOwnedValue.core.header.ruleset.type !== "group") {
|
||||
throw new Error("Can only accept invites to groups");
|
||||
}
|
||||
|
||||
const group = new Group(expectGroupContent(groupOrOwnedValue), this);
|
||||
const group = expectGroup(groupOrOwnedValue);
|
||||
|
||||
const inviteAgentSecret = agentSecretFromSecretSeed(
|
||||
secretSeedFromInviteSecret(inviteSecret)
|
||||
@@ -232,8 +321,8 @@ export class LocalNode {
|
||||
const inviteAgentID = getAgentID(inviteAgentSecret);
|
||||
|
||||
const inviteRole = await new Promise((resolve, reject) => {
|
||||
group.underlyingMap.subscribe((groupMap) => {
|
||||
const role = groupMap.get(inviteAgentID);
|
||||
group.subscribe((groupUpdate) => {
|
||||
const role = groupUpdate.get(inviteAgentID);
|
||||
if (role) {
|
||||
resolve(role);
|
||||
}
|
||||
@@ -248,7 +337,7 @@ export class LocalNode {
|
||||
throw new Error("No invite found");
|
||||
}
|
||||
|
||||
const existingRole = group.underlyingMap.get(this.account.id);
|
||||
const existingRole = group.get(this.account.id);
|
||||
|
||||
if (
|
||||
existingRole === "admin" ||
|
||||
@@ -262,9 +351,13 @@ export class LocalNode {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupAsInvite = group.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteAgentSecret),
|
||||
newRandomSessionID(inviteAgentID)
|
||||
const groupAsInvite = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteAgentSecret),
|
||||
newRandomSessionID(inviteAgentID)
|
||||
)
|
||||
.getCurrentContent()
|
||||
);
|
||||
|
||||
groupAsInvite.addMemberInternal(
|
||||
@@ -276,12 +369,11 @@ export class LocalNode {
|
||||
: "reader"
|
||||
);
|
||||
|
||||
group.underlyingMap.core._sessions =
|
||||
groupAsInvite.underlyingMap.core.sessions;
|
||||
group.underlyingMap.core._cachedContent = undefined;
|
||||
group.core._sessions = groupAsInvite.core.sessions;
|
||||
group.core._cachedContent = undefined;
|
||||
|
||||
for (const groupListener of group.underlyingMap.core.listeners) {
|
||||
groupListener(group.underlyingMap.core.getCurrentContent());
|
||||
for (const groupListener of group.core.listeners) {
|
||||
groupListener(group.core.getCurrentContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +398,7 @@ export class LocalNode {
|
||||
/** @internal */
|
||||
expectProfileLoaded(id: AccountID, expectation?: string): Profile {
|
||||
const account = this.expectCoValueLoaded(id, expectation);
|
||||
const profileID = expectGroupContent(account.getCurrentContent()).get(
|
||||
const profileID = expectGroup(account.getCurrentContent()).get(
|
||||
"profile"
|
||||
);
|
||||
if (!profileID) {
|
||||
@@ -327,62 +419,65 @@ export class LocalNode {
|
||||
name: string,
|
||||
agentSecret = newRandomAgentSecret()
|
||||
): ControlledAccount {
|
||||
const account = this.createCoValue(
|
||||
accountHeaderForInitialAgentSecret(agentSecret)
|
||||
).testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
newRandomSessionID(getAgentID(agentSecret))
|
||||
const accountAgentID = getAgentID(agentSecret);
|
||||
let account = expectGroup(
|
||||
this.createCoValue(accountHeaderForInitialAgentSecret(agentSecret))
|
||||
.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
newRandomSessionID(accountAgentID)
|
||||
)
|
||||
.getCurrentContent()
|
||||
);
|
||||
|
||||
const accountAsGroup = new Group(
|
||||
expectGroupContent(account.getCurrentContent()),
|
||||
account.node
|
||||
);
|
||||
|
||||
accountAsGroup.underlyingMap.edit((editable) => {
|
||||
editable.set(getAgentID(agentSecret), "admin", "trusting");
|
||||
account = account.mutate((editable) => {
|
||||
editable.set(accountAgentID, "admin", "trusting");
|
||||
|
||||
const readKey = newRandomKeySecret();
|
||||
|
||||
const sealed = seal({
|
||||
message: readKey.secret,
|
||||
from: getAgentSealerSecret(agentSecret),
|
||||
to: getAgentSealerID(accountAgentID),
|
||||
nOnceMaterial: {
|
||||
in: account.id,
|
||||
tx: account.core.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
"Creating read key",
|
||||
getAgentSealerSecret(agentSecret),
|
||||
getAgentSealerID(accountAgentID),
|
||||
account.id,
|
||||
account.core.nextTransactionID(),
|
||||
"in session",
|
||||
account.core.node.currentSessionID,
|
||||
"=",
|
||||
sealed
|
||||
);
|
||||
editable.set(
|
||||
`${readKey.id}_for_${getAgentID(agentSecret)}`,
|
||||
seal(
|
||||
readKey.secret,
|
||||
getAgentSealerSecret(agentSecret),
|
||||
getAgentSealerID(getAgentID(agentSecret)),
|
||||
{
|
||||
in: account.id,
|
||||
tx: account.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
`${readKey.id}_for_${accountAgentID}`,
|
||||
sealed,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
editable.set("readKey", readKey.id, "trusting");
|
||||
});
|
||||
|
||||
const controlledAccount = new ControlledAccount(
|
||||
agentSecret,
|
||||
account.getCurrentContent() as CoMap<AccountContent, AccountMeta>,
|
||||
account.node
|
||||
const profile = account.createMap<Profile>(
|
||||
{ name },
|
||||
{
|
||||
type: "profile",
|
||||
},
|
||||
"trusting"
|
||||
);
|
||||
|
||||
let profile = accountAsGroup.createMap<Profile>(undefined, {
|
||||
type: "profile",
|
||||
});
|
||||
|
||||
profile = profile.edit((editable) => {
|
||||
editable.set("name", name, "trusting");
|
||||
});
|
||||
|
||||
accountAsGroup.underlyingMap.edit((editable) => {
|
||||
editable.set("profile", profile.id, "trusting");
|
||||
});
|
||||
account = account.set("profile", profile.id, "trusting");
|
||||
|
||||
const accountOnThisNode = this.expectCoValueLoaded(account.id);
|
||||
|
||||
accountOnThisNode._sessions = {
|
||||
...accountAsGroup.underlyingMap.core.sessions,
|
||||
...account.core.sessions,
|
||||
};
|
||||
accountOnThisNode._cachedContent = undefined;
|
||||
|
||||
@@ -393,7 +488,7 @@ export class LocalNode {
|
||||
};
|
||||
profileOnThisNode._cachedContent = undefined;
|
||||
|
||||
return controlledAccount;
|
||||
return new ControlledAccount(accountOnThisNode, agentSecret);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -421,13 +516,12 @@ export class LocalNode {
|
||||
);
|
||||
}
|
||||
|
||||
return new AccountGroup(
|
||||
coValue.getCurrentContent() as CoMap<GroupContent, AccountMeta>,
|
||||
this
|
||||
).getCurrentAgentID();
|
||||
return new Account(coValue).getCurrentAgentID();
|
||||
}
|
||||
|
||||
/** Creates a new group (with the current account as the group's first admin). */
|
||||
/**
|
||||
* @deprecated use Account.createGroup() instead
|
||||
*/
|
||||
createGroup(): Group {
|
||||
const groupCoValue = this.createCoValue({
|
||||
type: "comap",
|
||||
@@ -436,31 +530,31 @@ export class LocalNode {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
let groupContent = expectGroupContent(groupCoValue.getCurrentContent());
|
||||
let group = expectGroup(groupCoValue.getCurrentContent());
|
||||
|
||||
groupContent = groupContent.edit((editable) => {
|
||||
group = group.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"
|
||||
);
|
||||
|
||||
editable.set("readKey", readKey.id, "trusting");
|
||||
});
|
||||
|
||||
return new Group(groupContent, this);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -505,6 +599,15 @@ export class LocalNode {
|
||||
}
|
||||
}
|
||||
|
||||
if (account instanceof ControlledAccount) {
|
||||
// To make sure that when we edit the account, we're modifying the correct sessions
|
||||
const accountInNode = new ControlledAccount(newNode.expectCoValueLoaded(account.id), account.agentSecret);
|
||||
if (accountInNode.core.node !== newNode) {
|
||||
throw new Error("Account's node is not the new node");
|
||||
}
|
||||
newNode.account = accountInNode;
|
||||
}
|
||||
|
||||
return newNode;
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,5 @@ import { BinaryCoStream } from './coValues/coStream.js'
|
||||
export type ImageDefinition = CoMap<{
|
||||
originalSize: [number, number];
|
||||
placeholderDataURL?: string;
|
||||
[res: `${number}x${number}`]: BinaryCoStream;
|
||||
[res: `${number}x${number}`]: BinaryCoStream["id"];
|
||||
}>;
|
||||
@@ -1,21 +1,16 @@
|
||||
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,
|
||||
TrustingTransaction,
|
||||
accountOrAgentIDfromSessionID,
|
||||
} from "./coValueCore.js";
|
||||
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import {
|
||||
AccountID,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
import { Account, AccountID, Profile } from "./coValues/account.js";
|
||||
import { parseJSON } from "./jsonStringify.js";
|
||||
import { EVERYONE, Everyone, expectGroup } from "./coValues/group.js";
|
||||
|
||||
export type PermissionsDef =
|
||||
| { type: "group"; initialAdmin: AccountID | AgentID }
|
||||
@@ -35,26 +30,21 @@ export function determineValidTransactions(
|
||||
coValue: CoValueCore
|
||||
): { txID: TransactionID; tx: Transaction }[] {
|
||||
if (coValue.header.ruleset.type === "group") {
|
||||
const allTrustingTransactionsSorted = Object.entries(
|
||||
coValue.sessions
|
||||
).flatMap(([sessionID, sessionLog]) => {
|
||||
return sessionLog.transactions
|
||||
.map((tx, txIndex) => ({ sessionID, txIndex, tx }))
|
||||
.filter(({ tx }) => {
|
||||
if (tx.privacy === "trusting") {
|
||||
return true;
|
||||
} else {
|
||||
console.warn("Unexpected private transaction in Group");
|
||||
return false;
|
||||
}
|
||||
}) as {
|
||||
sessionID: SessionID;
|
||||
txIndex: number;
|
||||
tx: TrustingTransaction;
|
||||
}[];
|
||||
});
|
||||
const allTransactionsSorted = Object.entries(coValue.sessions).flatMap(
|
||||
([sessionID, sessionLog]) => {
|
||||
return sessionLog.transactions.map((tx, txIndex) => ({
|
||||
sessionID,
|
||||
txIndex,
|
||||
tx,
|
||||
})) as {
|
||||
sessionID: SessionID;
|
||||
txIndex: number;
|
||||
tx: Transaction;
|
||||
}[];
|
||||
}
|
||||
);
|
||||
|
||||
allTrustingTransactionsSorted.sort((a, b) => {
|
||||
allTransactionsSorted.sort((a, b) => {
|
||||
return a.tx.madeAt - b.tx.madeAt;
|
||||
});
|
||||
|
||||
@@ -64,23 +54,54 @@ export function determineValidTransactions(
|
||||
throw new Error("Group must have initialAdmin");
|
||||
}
|
||||
|
||||
const memberState: { [agent: AccountID | AgentID]: Role } = {};
|
||||
const memberState: {
|
||||
[agent: AccountID | AgentID]: Role;
|
||||
[EVERYONE]?: Role;
|
||||
} = {};
|
||||
|
||||
const validTransactions: { txID: TransactionID; tx: Transaction }[] =
|
||||
[];
|
||||
|
||||
for (const {
|
||||
sessionID,
|
||||
txIndex,
|
||||
tx,
|
||||
} of allTrustingTransactionsSorted) {
|
||||
for (const { sessionID, txIndex, tx } of allTransactionsSorted) {
|
||||
// console.log("before", { memberState, validTransactions });
|
||||
const transactor = accountOrAgentIDfromSessionID(sessionID);
|
||||
|
||||
const changes = parseJSON(tx.changes)
|
||||
if (tx.privacy === "private") {
|
||||
if (memberState[transactor] === "admin") {
|
||||
validTransactions.push({
|
||||
txID: { sessionID, txIndex },
|
||||
tx,
|
||||
});
|
||||
continue;
|
||||
} else {
|
||||
console.warn(
|
||||
"Only admins can make private transactions in groups"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
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<AccountID | AgentID | Everyone, Role>
|
||||
| MapOpPayload<"readKey", JsonValue>
|
||||
| MapOpPayload<"profile", CoID<Profile>>;
|
||||
if (changes.length !== 1) {
|
||||
@@ -145,6 +166,20 @@ export function determineValidTransactions(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
affectedMember === EVERYONE &&
|
||||
!(
|
||||
change.value === "reader" ||
|
||||
change.value === "writer" ||
|
||||
change.value === "revoked"
|
||||
)
|
||||
) {
|
||||
console.warn(
|
||||
"Everyone can only be set to reader, writer or revoked"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isFirstSelfAppointment =
|
||||
!memberState[transactor] &&
|
||||
transactor === initialAdmin &&
|
||||
@@ -193,12 +228,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 = expectGroup(
|
||||
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");
|
||||
@@ -209,12 +246,18 @@ export function determineValidTransactions(
|
||||
const transactor = accountOrAgentIDfromSessionID(
|
||||
sessionID as SessionID
|
||||
);
|
||||
|
||||
return sessionLog.transactions
|
||||
.filter((tx) => {
|
||||
const transactorRoleAtTxTime = groupContent.getAtTime(
|
||||
transactor,
|
||||
tx.madeAt
|
||||
);
|
||||
const groupAtTime = groupContent.atTime(tx.madeAt);
|
||||
const effectiveTransactor =
|
||||
transactor === groupContent.id &&
|
||||
groupAtTime instanceof Account
|
||||
? groupAtTime.getCurrentAgentID()
|
||||
: transactor;
|
||||
const transactorRoleAtTxTime =
|
||||
groupAtTime.get(effectiveTransactor) ||
|
||||
groupAtTime.get(EVERYONE);
|
||||
|
||||
return (
|
||||
transactorRoleAtTxTime === "admin" ||
|
||||
@@ -238,7 +281,8 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -253,7 +297,8 @@ export function isKeyForAccountField(
|
||||
field: string
|
||||
): field is `${KeyID}_for_${AccountID | AgentID}` {
|
||||
return (
|
||||
field.startsWith("key_") &&
|
||||
(field.includes("_for_sealer") || field.includes("_for_co"))
|
||||
(field.startsWith("key_") &&
|
||||
(field.includes("_for_sealer") || field.includes("_for_co"))) ||
|
||||
field.includes("_for_everyone")
|
||||
);
|
||||
}
|
||||
|
||||
40
packages/cojson/src/queriedCoValues/queriedAccount.ts
Normal file
40
packages/cojson/src/queriedCoValues/queriedAccount.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Account } from "../coValues/account.js";
|
||||
import { CoID, CoValue, ControlledAccount, InviteSecret } from "../index.js";
|
||||
import { QueryContext } from "../queries.js";
|
||||
import { QueriedGroup } from "./queriedGroup.js";
|
||||
|
||||
export class QueriedAccount<A extends Account = Account> extends QueriedGroup<A> {
|
||||
id!: CoID<A>;
|
||||
isMe!: boolean;
|
||||
|
||||
constructor(account: A, queryContext: QueryContext) {
|
||||
super(account, queryContext);
|
||||
Object.defineProperties(this, {
|
||||
id: { value: account.id, enumerable: false },
|
||||
isMe: {
|
||||
value: account.core.node.account.id === account.id,
|
||||
enumerable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
createGroup() {
|
||||
if (!this.isMe)
|
||||
throw new Error("Only the current user can create a group");
|
||||
return (
|
||||
this.group.core.node.account as ControlledAccount
|
||||
).createGroup();
|
||||
}
|
||||
|
||||
acceptInvite<T extends CoValue>(
|
||||
groupOrOwnedValueID: CoID<T>,
|
||||
inviteSecret: InviteSecret
|
||||
) {
|
||||
if (!this.isMe)
|
||||
throw new Error("Only the current user can accept an invite");
|
||||
return (this.group.core.node.account as ControlledAccount).acceptInvite(
|
||||
groupOrOwnedValueID,
|
||||
inviteSecret
|
||||
);
|
||||
}
|
||||
}
|
||||
240
packages/cojson/src/queriedCoValues/queriedCoList.ts
Normal file
240
packages/cojson/src/queriedCoValues/queriedCoList.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { CoList, MutableCoList } from "../coValues/coList.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { Group } from "../coValues/group.js";
|
||||
import { CoID, CoValue } from "../coValue.js";
|
||||
import { TransactionID } from "../ids.js";
|
||||
import { ValueOrSubQueried, QueryContext } from "../queries.js";
|
||||
import { QueriedAccount } from "./queriedAccount.js";
|
||||
|
||||
export class QueriedCoList<L extends CoList> 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.queryIfCoID(item, [coList.id]) as ValueOrSubQueried<
|
||||
L["_item"]
|
||||
>
|
||||
)
|
||||
);
|
||||
|
||||
Object.defineProperties(this, {
|
||||
coList: { get() {return coList} },
|
||||
id: { value: coList.id },
|
||||
type: { value: "colist" },
|
||||
edits: {
|
||||
value: [...this.keys()].map((i) => {
|
||||
const edit = coList.editAt(i)!;
|
||||
return queryContext.defineSubqueryPropertiesIn({
|
||||
|
||||
tx: edit.tx,
|
||||
at: new Date(edit.at),
|
||||
}, {
|
||||
by: {value: edit.by, enumerable: true},
|
||||
value: {value: edit.value, enumerable: true},
|
||||
}, [coList.id]);
|
||||
}),
|
||||
},
|
||||
deletions: {
|
||||
value: coList.deletionEdits().map((deletion) => queryContext.defineSubqueryPropertiesIn({
|
||||
|
||||
tx: deletion.tx,
|
||||
at: new Date(deletion.at),
|
||||
}, {
|
||||
by: {value: deletion.by, enumerable: true},
|
||||
}, [coList.id])),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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"],
|
||||
after?: number,
|
||||
privacy?: "private" | "trusting"
|
||||
): L {
|
||||
return this.coList.append(item, after, privacy);
|
||||
}
|
||||
|
||||
prepend(
|
||||
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?: QueriedAccount;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: L["_item"] extends CoValue
|
||||
? CoID<L["_item"]>
|
||||
: Exclude<L["_item"], CoValue>;
|
||||
}[];
|
||||
|
||||
deletions!: {
|
||||
by?: QueriedAccount;
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
168
packages/cojson/src/queriedCoValues/queriedCoMap.ts
Normal file
168
packages/cojson/src/queriedCoValues/queriedCoMap.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { CoMap, MutableCoMap } from "../coValues/coMap.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { Group } from "../coValues/group.js";
|
||||
import { CoID } from "../coValue.js";
|
||||
import { TransactionID } from "../ids.js";
|
||||
import { ValueOrSubQueried, QueryContext, QueryExtension } from "../queries.js";
|
||||
import { QueriedAccount } from "./queriedAccount.js";
|
||||
|
||||
export type QueriedCoMap<M extends CoMap> = {
|
||||
[K in keyof M["_shape"] & string]: ValueOrSubQueried<M["_shape"][K]>;
|
||||
} & QueriedCoMapBase<M>;
|
||||
|
||||
export type QueriedCoMapEdit<M extends CoMap, K extends keyof M["_shape"]> = {
|
||||
by?: QueriedAccount;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: M["_shape"][K];
|
||||
};
|
||||
|
||||
export class QueriedCoMapBase<M extends CoMap> {
|
||||
coMap!: M;
|
||||
id!: CoID<M>;
|
||||
type!: "comap";
|
||||
|
||||
/** @internal */
|
||||
static newWithKVPairs<M extends CoMap>(
|
||||
coMap: M,
|
||||
queryContext: QueryContext
|
||||
): QueriedCoMap<M> {
|
||||
const kv = {} as {
|
||||
[K in keyof M["_shape"] & string]: ValueOrSubQueried<
|
||||
M["_shape"][K]
|
||||
>;
|
||||
};
|
||||
for (const key of coMap.keys()) {
|
||||
const value = coMap.get(key);
|
||||
|
||||
if (value === undefined) continue;
|
||||
|
||||
queryContext.defineSubqueryPropertiesIn(
|
||||
kv,
|
||||
{
|
||||
[key]: { value, enumerable: true },
|
||||
},
|
||||
[coMap.id]
|
||||
);
|
||||
}
|
||||
|
||||
return Object.assign(new QueriedCoMapBase(coMap, queryContext), kv);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
constructor(coMap: M, queryContext: QueryContext) {
|
||||
Object.defineProperties(this, {
|
||||
coMap: {
|
||||
get() {
|
||||
return 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) =>
|
||||
queryContext.defineSubqueryPropertiesIn(
|
||||
{
|
||||
tx: edit.tx,
|
||||
at: new Date(edit.at),
|
||||
},
|
||||
{
|
||||
by: { value: edit.by, enumerable: true },
|
||||
value: {
|
||||
value: edit.value,
|
||||
enumerable: true,
|
||||
},
|
||||
},
|
||||
[coMap.id]
|
||||
)
|
||||
);
|
||||
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,
|
||||
},
|
||||
as: {
|
||||
value: <O>(extension: QueryExtension<M, O>) => {
|
||||
return queryContext.getOrCreateExtension(
|
||||
coMap.id,
|
||||
extension
|
||||
);
|
||||
},
|
||||
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],
|
||||
privacy?: "private" | "trusting"
|
||||
): M;
|
||||
set(
|
||||
kv: {
|
||||
[K in keyof M["_shape"] & string]?: M["_shape"][K];
|
||||
},
|
||||
privacy?: "private" | "trusting"
|
||||
): M;
|
||||
set<K extends keyof M["_shape"] & string>(
|
||||
...args:
|
||||
| [
|
||||
{
|
||||
[K in keyof M["_shape"] & string]?: M["_shape"][K];
|
||||
},
|
||||
("private" | "trusting")?
|
||||
]
|
||||
| [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);
|
||||
}
|
||||
|
||||
as!: <O>(extension: QueryExtension<M, O>) => O | undefined;
|
||||
}
|
||||
151
packages/cojson/src/queriedCoValues/queriedCoStream.ts
Normal file
151
packages/cojson/src/queriedCoValues/queriedCoStream.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { JsonValue } from "../jsonValue.js";
|
||||
import { CoStream, MutableCoStream } from "../coValues/coStream.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { Group } from "../coValues/group.js";
|
||||
import { AccountID, isAccountID } from "../coValues/account.js";
|
||||
import { CoID, CoValue } from "../coValue.js";
|
||||
import { SessionID, TransactionID } from "../ids.js";
|
||||
import { ValueOrSubQueried, QueryContext } from "../queries.js";
|
||||
import { QueriedAccount } from "./queriedAccount.js";
|
||||
|
||||
export type QueriedCoStreamEntry<Item extends JsonValue | CoValue> = {
|
||||
last?: ValueOrSubQueried<Item>;
|
||||
by?: QueriedAccount;
|
||||
tx?: TransactionID;
|
||||
at?: Date;
|
||||
all: {
|
||||
value: ValueOrSubQueried<Item>;
|
||||
by?: QueriedAccount;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
}[];
|
||||
};
|
||||
|
||||
export class QueriedCoStream<S extends CoStream> {
|
||||
coStream!: S;
|
||||
id: CoID<S>;
|
||||
type = "costream" as const;
|
||||
|
||||
/** @internal */
|
||||
constructor(coStream: S, queryContext: QueryContext) {
|
||||
Object.defineProperty(this, "coStream", {
|
||||
get() {
|
||||
return coStream;
|
||||
},
|
||||
});
|
||||
this.id = coStream.id;
|
||||
|
||||
this.perSession = coStream.sessions().map((sessionID) => {
|
||||
const items = [...coStream.itemsIn(sessionID)].map((item) =>
|
||||
queryContext.defineSubqueryPropertiesIn(
|
||||
{
|
||||
tx: item.tx,
|
||||
at: new Date(item.at),
|
||||
},
|
||||
{
|
||||
by: {
|
||||
value: isAccountID(item.by)
|
||||
? item.by
|
||||
: (undefined as never),
|
||||
enumerable: true,
|
||||
},
|
||||
value: {
|
||||
value: item.value as S["_item"],
|
||||
enumerable: true,
|
||||
},
|
||||
},
|
||||
[coStream.id]
|
||||
)
|
||||
);
|
||||
|
||||
const lastItem = items[items.length - 1];
|
||||
|
||||
return [
|
||||
sessionID,
|
||||
{
|
||||
get last() {
|
||||
return lastItem?.value;
|
||||
},
|
||||
get by() {
|
||||
return lastItem?.by;
|
||||
},
|
||||
tx: lastItem?.tx,
|
||||
at: lastItem?.at,
|
||||
all: items,
|
||||
} satisfies QueriedCoStreamEntry<S["_item"]>,
|
||||
];
|
||||
});
|
||||
|
||||
this.perAccount = [...coStream.accounts()].map((accountID) => {
|
||||
const items = [...coStream.itemsBy(accountID)].map((item) =>
|
||||
queryContext.defineSubqueryPropertiesIn(
|
||||
{
|
||||
tx: item.tx,
|
||||
at: new Date(item.at),
|
||||
},
|
||||
{
|
||||
by: {
|
||||
value: isAccountID(item.by)
|
||||
? item.by
|
||||
: (undefined as never),
|
||||
enumerable: true,
|
||||
},
|
||||
value: {
|
||||
value: item.value as S["_item"],
|
||||
enumerable: true,
|
||||
},
|
||||
},
|
||||
[coStream.id]
|
||||
)
|
||||
);
|
||||
|
||||
const lastItem = items[items.length - 1];
|
||||
|
||||
const entry = {
|
||||
get last() {
|
||||
return lastItem?.value;
|
||||
},
|
||||
get by() {
|
||||
return lastItem?.by;
|
||||
},
|
||||
tx: lastItem?.tx,
|
||||
at: lastItem?.at,
|
||||
all: items,
|
||||
} satisfies QueriedCoStreamEntry<S["_item"]>;
|
||||
|
||||
if (accountID === queryContext.node.account.id) {
|
||||
this.me = entry;
|
||||
}
|
||||
|
||||
return [
|
||||
accountID,
|
||||
entry
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
get meta(): S["meta"] {
|
||||
return this.coStream.meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.coStream.group;
|
||||
}
|
||||
|
||||
get core(): CoValueCore {
|
||||
return this.coStream.core;
|
||||
}
|
||||
|
||||
me?: QueriedCoStreamEntry<S["_item"]>;
|
||||
perAccount: [account: AccountID, items: QueriedCoStreamEntry<S["_item"]>][];
|
||||
perSession: [session: SessionID, items: QueriedCoStreamEntry<S["_item"]>][];
|
||||
|
||||
push(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);
|
||||
}
|
||||
}
|
||||
90
packages/cojson/src/queriedCoValues/queriedGroup.ts
Normal file
90
packages/cojson/src/queriedCoValues/queriedGroup.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Everyone, Group, InviteSecret } from "../coValues/group.js";
|
||||
import { CoID } from "../coValue.js";
|
||||
import { QueryContext, ValueOrSubQueried } from "../queries.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { Role } from "../permissions.js";
|
||||
import { AccountID } from "../coValues/account.js";
|
||||
import { CoMap } from "../coValues/coMap.js";
|
||||
import { CoList } from "../coValues/coList.js";
|
||||
import { CoStream } from "../coValues/coStream.js";
|
||||
import { BinaryCoStream } from "../coValues/coStream.js";
|
||||
|
||||
export class QueriedGroup<G extends Group = Group> {
|
||||
group!: G;
|
||||
id!: CoID<G>;
|
||||
type = "group" as const;
|
||||
profile?: ValueOrSubQueried<G["_shape"]["profile"]>;
|
||||
root?: ValueOrSubQueried<G["_shape"]["root"]>;
|
||||
|
||||
constructor(group: G, queryContext: QueryContext) {
|
||||
const profileID = group.get("profile");
|
||||
const rootID = group.get("root");
|
||||
queryContext.defineSubqueryPropertiesIn(Object.defineProperties(this, {
|
||||
group: {
|
||||
get() {
|
||||
return group;
|
||||
},
|
||||
enumerable: false,
|
||||
},
|
||||
id: { value: group.id, enumerable: false },
|
||||
type: { value: "group", enumerable: false },
|
||||
}), {
|
||||
profile: {
|
||||
value: profileID,
|
||||
enumerable: false,
|
||||
},
|
||||
root: {
|
||||
value: rootID,
|
||||
enumerable: false,
|
||||
},
|
||||
}, [group.id]);
|
||||
}
|
||||
|
||||
get meta(): G["meta"] {
|
||||
return this.group.meta;
|
||||
}
|
||||
|
||||
get core(): CoValueCore {
|
||||
return this.group.core;
|
||||
}
|
||||
|
||||
addMember(accountID: AccountID | Everyone, role: Role): G {
|
||||
return this.group.addMember(accountID, role);
|
||||
}
|
||||
|
||||
removeMember(accountID: AccountID): G {
|
||||
return this.group.removeMember(accountID);
|
||||
}
|
||||
|
||||
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
|
||||
return this.group.createInvite(role);
|
||||
}
|
||||
|
||||
createMap<M extends CoMap>(
|
||||
init?: {
|
||||
[K in keyof M["_shape"]]: M["_shape"][K];
|
||||
},
|
||||
meta?: M["meta"],
|
||||
initPrivacy: "trusting" | "private" = "private"
|
||||
): M {
|
||||
return this.group.createMap(init, meta, initPrivacy);
|
||||
}
|
||||
|
||||
createList<L extends CoList>(
|
||||
init?: L["_item"][],
|
||||
meta?: L["meta"],
|
||||
initPrivacy: "trusting" | "private" = "private"
|
||||
): L {
|
||||
return this.group.createList(init, meta, initPrivacy);
|
||||
}
|
||||
|
||||
createStream<C extends CoStream>(meta?: C["meta"]): C {
|
||||
return this.group.createStream(meta);
|
||||
}
|
||||
|
||||
createBinaryStream<C extends BinaryCoStream>(
|
||||
meta: C["meta"] = { type: "binary" }
|
||||
): C {
|
||||
return this.group.createBinaryStream(meta);
|
||||
}
|
||||
}
|
||||
@@ -1,188 +1,38 @@
|
||||
import { JsonValue } from "./jsonValue.js";
|
||||
import { CoMap, WriteableCoMap } from "./coValues/coMap.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { CoStream } from "./coValues/coStream.js";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import { Account, AccountID } from "./coValues/account.js";
|
||||
import { CoID, CoValue } from "./coValue.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import {
|
||||
BinaryCoStream,
|
||||
BinaryStreamInfo,
|
||||
CoStream,
|
||||
WriteableBinaryCoStream,
|
||||
WriteableCoStream,
|
||||
} from "./coValues/coStream.js";
|
||||
import { Static } from "./coValues/static.js";
|
||||
import { CoList, WriteableCoList } from "./coValues/coList.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "./coValueCore.js";
|
||||
import { Group } from "./group.js";
|
||||
import { AccountID, Profile, isAccountID } from "./account.js";
|
||||
import {
|
||||
AnyBinaryCoStream,
|
||||
AnyCoList,
|
||||
AnyCoMap,
|
||||
AnyCoStream,
|
||||
AnyCoValue,
|
||||
AnyStatic,
|
||||
CoID,
|
||||
CoValue,
|
||||
} from "./coValue.js";
|
||||
import { SessionID } from "./ids.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
QueriedCoMap,
|
||||
QueriedCoMapBase,
|
||||
} from "./queriedCoValues/queriedCoMap.js";
|
||||
import { QueriedCoList } from "./queriedCoValues/queriedCoList.js";
|
||||
import { QueriedCoStream } from "./queriedCoValues/queriedCoStream.js";
|
||||
import { Group } from "./coValues/group.js";
|
||||
import { QueriedAccount } from "./queriedCoValues/queriedAccount.js";
|
||||
import { QueriedGroup } from "./queriedCoValues/queriedGroup.js";
|
||||
|
||||
export const AllReservedQueryProps = [
|
||||
"id",
|
||||
"type",
|
||||
"meta",
|
||||
"core",
|
||||
"group",
|
||||
"shadowed",
|
||||
"edit",
|
||||
"edits",
|
||||
] as const;
|
||||
|
||||
export type ReservedQueryProps = (typeof AllReservedQueryProps)[number];
|
||||
|
||||
export type QueriedCoMap<T extends AnyCoMap> = T extends CoMap<
|
||||
infer M,
|
||||
infer Meta
|
||||
>
|
||||
? Readonly<{
|
||||
[K in keyof M as Exclude<K, ReservedQueryProps>]: ValueOrSubQueried<
|
||||
M[K]
|
||||
>;
|
||||
}> &
|
||||
(keyof M & ReservedQueryProps extends never
|
||||
? // eslint-disable-next-line @typescript-eslint/ban-types
|
||||
{}
|
||||
: Readonly<{
|
||||
shadowed: Readonly<{
|
||||
[K in keyof M as Extract<
|
||||
K,
|
||||
ReservedQueryProps
|
||||
>]: ValueOrSubQueried<M[K]>;
|
||||
}>;
|
||||
}>) &
|
||||
Readonly<{
|
||||
id: CoID<T>;
|
||||
type: "comap";
|
||||
edits: Readonly<{
|
||||
[K in keyof M & string]: Readonly<{
|
||||
by?: QueriedAccountAndProfile;
|
||||
at: Date;
|
||||
// all: TODO;
|
||||
}>;
|
||||
}>;
|
||||
meta: Meta;
|
||||
group: Group;
|
||||
core: CoValueCore;
|
||||
edit: (changer: (editable: WriteableCoMap<M, Meta>) => void) => T;
|
||||
}>
|
||||
: never;
|
||||
|
||||
export type QueriedAccountAndProfile = Readonly<{
|
||||
id: AccountID;
|
||||
profile?: Readonly<{ name?: string; id: CoID<Profile> }>;
|
||||
isMe?: boolean;
|
||||
}>;
|
||||
|
||||
export type QueriedCoList<T extends AnyCoList> = T extends CoList<
|
||||
infer I,
|
||||
infer Meta
|
||||
>
|
||||
? readonly ValueOrSubQueried<I>[] &
|
||||
Readonly<{
|
||||
id: CoID<T>;
|
||||
type: "colist";
|
||||
meta: Meta;
|
||||
group: Group;
|
||||
core: CoValueCore;
|
||||
edit: (
|
||||
changer: (editable: WriteableCoList<I, Meta>) => void
|
||||
) => T;
|
||||
edits: readonly Readonly<{
|
||||
by?: QueriedAccountAndProfile;
|
||||
at: Date;
|
||||
}>[] & {
|
||||
// deletions: TODO;
|
||||
};
|
||||
}>
|
||||
: never;
|
||||
|
||||
export type QueriedCoStreamItems<I extends JsonValue | CoValue> = Readonly<{
|
||||
last: ValueOrSubQueried<I> | undefined;
|
||||
by?: QueriedAccountAndProfile;
|
||||
at?: Date;
|
||||
all: { value: ValueOrSubQueried<I>; at: Date }[];
|
||||
}>;
|
||||
|
||||
export type QueriedCoStream<T extends AnyCoStream> = T extends CoStream<
|
||||
infer I,
|
||||
infer Meta
|
||||
>
|
||||
? Readonly<{
|
||||
id: CoID<T>;
|
||||
type: "costream";
|
||||
me?: QueriedCoStreamItems<I>;
|
||||
perAccount: Readonly<{
|
||||
[account: AccountID]: QueriedCoStreamItems<I>;
|
||||
}>;
|
||||
perSession: Readonly<{
|
||||
[session: SessionID]: QueriedCoStreamItems<I>;
|
||||
}>;
|
||||
meta: Meta;
|
||||
group: Group;
|
||||
core: CoValueCore;
|
||||
edit: (changer: (editable: WriteableCoStream<I, Meta>) => void) => T;
|
||||
}>
|
||||
: never;
|
||||
|
||||
export type QueriedBinaryCoStreamItems = Readonly<{
|
||||
last: Uint8Array | undefined;
|
||||
by: QueriedAccountAndProfile;
|
||||
at: Date;
|
||||
all: { value: Uint8Array; at: Date }[];
|
||||
}>;
|
||||
|
||||
export type QueriedBinaryCoStream<T extends AnyBinaryCoStream> =
|
||||
T extends BinaryCoStream<infer Meta>
|
||||
? Readonly<
|
||||
{
|
||||
id: CoID<T>;
|
||||
type: "costream";
|
||||
me?: QueriedBinaryCoStreamItems;
|
||||
perAccount: Readonly<{
|
||||
[account: AccountID]: QueriedBinaryCoStreamItems;
|
||||
}>;
|
||||
perSession: Readonly<{
|
||||
[session: SessionID]: QueriedBinaryCoStreamItems;
|
||||
}>;
|
||||
meta: Meta;
|
||||
group: Group;
|
||||
core: CoValueCore;
|
||||
edit: (
|
||||
changer: (editable: WriteableBinaryCoStream<Meta>) => void
|
||||
) => T;
|
||||
}
|
||||
> & Readonly<BinaryStreamInfo>
|
||||
: never;
|
||||
|
||||
export type QueriedStatic<T extends AnyStatic> = T extends Static<infer Meta>
|
||||
? Readonly<{
|
||||
id: CoID<T>;
|
||||
type: "colist";
|
||||
meta: Meta;
|
||||
group: Group;
|
||||
core: CoValueCore;
|
||||
}>
|
||||
: never;
|
||||
|
||||
export type Queried<T extends CoValue> = T extends AnyCoMap
|
||||
? QueriedCoMap<T>
|
||||
: T extends AnyCoList
|
||||
export type Queried<T extends CoValue> = T extends CoMap
|
||||
? T extends Account
|
||||
? QueriedAccount<T>
|
||||
: T extends Group
|
||||
? QueriedGroup<T>
|
||||
: QueriedCoMap<T>
|
||||
: T extends CoList
|
||||
? QueriedCoList<T>
|
||||
// : T extends BinaryCoStream<infer _>
|
||||
// ? QueriedBinaryCoStream<T>
|
||||
: T extends AnyCoStream
|
||||
? QueriedCoStream<T>
|
||||
: T extends AnyStatic
|
||||
? QueriedStatic<T>
|
||||
: never;
|
||||
: T extends CoStream
|
||||
? T["meta"] extends { type: "binary" }
|
||||
? never
|
||||
: QueriedCoStream<T>
|
||||
:
|
||||
| QueriedAccount
|
||||
| QueriedGroup
|
||||
| QueriedCoMap<CoMap>
|
||||
| QueriedCoList<CoList>
|
||||
| QueriedCoStream<CoStream>;
|
||||
|
||||
export type ValueOrSubQueried<
|
||||
V extends JsonValue | CoValue | CoID<CoValue> | undefined
|
||||
@@ -192,328 +42,222 @@ export type ValueOrSubQueried<
|
||||
? Queried<V> | undefined
|
||||
: V;
|
||||
|
||||
export type QueryInclude<T extends CoValue> = T extends CoMap<
|
||||
infer M,
|
||||
infer _Meta
|
||||
>
|
||||
? {
|
||||
[K in keyof M as M[K] extends AnyCoValue | CoID<AnyCoValue>
|
||||
? K
|
||||
: never]?: M[K] extends AnyCoValue
|
||||
? true | QueryInclude<M[K]>
|
||||
: M[K] extends CoID<infer S>
|
||||
? true | QueryInclude<S>
|
||||
: never;
|
||||
}
|
||||
: T extends CoList<infer I, infer _>
|
||||
? I extends AnyCoValue
|
||||
? [true] | [QueryInclude<I>]
|
||||
: I extends CoID<infer S>
|
||||
? [true] | [QueryInclude<S>]
|
||||
: never
|
||||
: never; // TODO add CoStream;
|
||||
export interface CleanupCallbackAndUsable {
|
||||
(): void;
|
||||
[Symbol.dispose]: () => void;
|
||||
}
|
||||
|
||||
export interface QueryExtension<T extends CoValue, O> {
|
||||
id: string;
|
||||
query(
|
||||
base: T,
|
||||
queryContext: QueryContext,
|
||||
onUpdate: (value: O) => void
|
||||
): () => void;
|
||||
}
|
||||
|
||||
export class QueryContext {
|
||||
values: {
|
||||
[id: CoID<CoValue>]: {
|
||||
lastUpdate: CoValue | undefined;
|
||||
lastQueried: Queried<CoValue> | undefined;
|
||||
render: () => void;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
} = {};
|
||||
extensions: {
|
||||
[id: `${CoID<CoValue>}_${string}`]: {
|
||||
lastOutput: unknown;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
} = {};
|
||||
node: LocalNode;
|
||||
onUpdate: () => void;
|
||||
|
||||
constructor(node: LocalNode, onUpdate: () => void) {
|
||||
this.node = node;
|
||||
this.onUpdate = onUpdate;
|
||||
}
|
||||
|
||||
query<T extends CoValue>(valueID: CoID<T>, alsoRender: CoID<CoValue>[]) {
|
||||
let value = this.values[valueID];
|
||||
if (!value) {
|
||||
const render = () => {
|
||||
let newQueried;
|
||||
const lastUpdate = value!.lastUpdate;
|
||||
|
||||
if (lastUpdate instanceof CoMap) {
|
||||
if (lastUpdate instanceof Account) {
|
||||
newQueried = new QueriedAccount(
|
||||
lastUpdate,
|
||||
this
|
||||
) as Queried<T>;
|
||||
} else if (lastUpdate instanceof Group) {
|
||||
newQueried = new QueriedGroup(
|
||||
lastUpdate,
|
||||
this
|
||||
) as Queried<T>;
|
||||
} else {
|
||||
newQueried = QueriedCoMapBase.newWithKVPairs(
|
||||
lastUpdate,
|
||||
this
|
||||
) as Queried<T>;
|
||||
}
|
||||
} else if (lastUpdate instanceof CoList) {
|
||||
newQueried = new QueriedCoList(
|
||||
lastUpdate,
|
||||
this
|
||||
) as Queried<T>;
|
||||
} else if (lastUpdate instanceof CoStream) {
|
||||
if (lastUpdate.meta?.type === "binary") {
|
||||
// Querying binary string not yet implemented
|
||||
} else {
|
||||
newQueried = new QueriedCoStream(
|
||||
lastUpdate,
|
||||
this
|
||||
) as Queried<T>;
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(
|
||||
// "Rendered ",
|
||||
// valueID,
|
||||
// lastUpdate?.constructor.name,
|
||||
// newQueried
|
||||
// );
|
||||
|
||||
value!.lastQueried = newQueried;
|
||||
|
||||
for (const alsoRenderID of alsoRender) {
|
||||
// console.log("Also rendering", alsoRenderID);
|
||||
this.values[alsoRenderID]?.render();
|
||||
}
|
||||
};
|
||||
|
||||
value = {
|
||||
lastQueried: undefined,
|
||||
lastUpdate: undefined,
|
||||
render,
|
||||
unsubscribe: this.node.subscribe(valueID, (valueUpdate) => {
|
||||
value!.lastUpdate = valueUpdate;
|
||||
value!.render();
|
||||
this.onUpdate();
|
||||
}),
|
||||
};
|
||||
this.values[valueID] = value;
|
||||
}
|
||||
return value.lastQueried as Queried<T> | undefined;
|
||||
}
|
||||
|
||||
queryIfCoID<T extends JsonValue | undefined>(value: T, alsoRender: CoID<CoValue>[]): T extends CoID<infer C> ? Queried<C> | undefined : T {
|
||||
if (typeof value === "string" && value.startsWith("co_")) {
|
||||
return this.query(value as CoID<CoValue>, alsoRender) as T extends CoID<infer C> ? Queried<C> | undefined : never;
|
||||
} else {
|
||||
return value as T extends CoID<infer C> ? Queried<C> | undefined : T;
|
||||
}
|
||||
}
|
||||
|
||||
valueOrSubQueryPropertyDescriptor<T extends JsonValue | undefined>(
|
||||
value: T,
|
||||
alsoRender: CoID<CoValue>[]
|
||||
): T extends CoID<infer C>
|
||||
? { get(): Queried<C> | undefined }
|
||||
: { value: T } {
|
||||
if (typeof value === "string" && value.startsWith("co_")) {
|
||||
// TODO: when we track render dirty status, we can actually return the queried value without a getter if it's up to date
|
||||
return {
|
||||
get: () => this.query(value as CoID<CoValue>, alsoRender),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { value: value } as any;
|
||||
}
|
||||
}
|
||||
|
||||
defineSubqueryPropertiesIn<
|
||||
O extends object,
|
||||
P extends {
|
||||
[key: string]: { value: JsonValue | undefined; enumerable: boolean };
|
||||
}
|
||||
>(
|
||||
obj: O,
|
||||
subqueryProps: P,
|
||||
alsoRender: CoID<CoValue>[]
|
||||
): O & {
|
||||
[Key in keyof P]: ValueOrSubQueried<P[Key]["value"]>;
|
||||
} {
|
||||
for (const [key, descriptor] of Object.entries(subqueryProps)) {
|
||||
Object.defineProperty(
|
||||
obj,
|
||||
key,
|
||||
{
|
||||
...this.valueOrSubQueryPropertyDescriptor(descriptor.value, alsoRender),
|
||||
enumerable: descriptor.enumerable,
|
||||
}
|
||||
);
|
||||
}
|
||||
return obj as O & {
|
||||
[Key in keyof P]: ValueOrSubQueried<P[Key]["value"]>
|
||||
};
|
||||
}
|
||||
|
||||
getOrCreateExtension<T extends CoValue, O>(
|
||||
valueID: CoID<T>,
|
||||
extension: QueryExtension<T, O>
|
||||
): O | undefined {
|
||||
const id = `${valueID}_${extension.id}`;
|
||||
let ext = this.extensions[id as keyof typeof this.extensions];
|
||||
if (!ext) {
|
||||
ext = {
|
||||
lastOutput: undefined,
|
||||
unsubscribe: extension.query(
|
||||
this.node
|
||||
.expectCoValueLoaded(valueID)
|
||||
.getCurrentContent() as T,
|
||||
this,
|
||||
(output) => {
|
||||
ext!.lastOutput = output;
|
||||
this.values[valueID]?.render();
|
||||
this.onUpdate();
|
||||
}
|
||||
),
|
||||
};
|
||||
this.extensions[id as keyof typeof this.extensions] = ext;
|
||||
}
|
||||
return ext.lastOutput as O | undefined;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const child of Object.values(this.values)) {
|
||||
child.unsubscribe?.();
|
||||
}
|
||||
for (const extension of Object.values(this.extensions)) {
|
||||
extension.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function query<T extends CoValue>(
|
||||
id: CoID<T>,
|
||||
node: LocalNode,
|
||||
callback: (queried: Queried<T> | undefined) => void
|
||||
): () => void {
|
||||
console.log("querying", id);
|
||||
): CleanupCallbackAndUsable {
|
||||
// console.log("querying", id);
|
||||
|
||||
const children: {
|
||||
[id: CoID<CoValue>]: {
|
||||
lastQueried: { [key: string]: any } | undefined;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
} = {};
|
||||
|
||||
const unsubscribe = node.subscribe(id, (update) => {
|
||||
lastRootValue = update;
|
||||
onUpdate();
|
||||
const context = new QueryContext(node, () => {
|
||||
const rootQueried = context.values[id]?.lastQueried as
|
||||
| Queried<T>
|
||||
| undefined;
|
||||
callback(rootQueried);
|
||||
});
|
||||
|
||||
function getChildLastQueriedOrSubscribe<T extends CoValue>(
|
||||
childID: CoID<T>
|
||||
) {
|
||||
let child = children[childID];
|
||||
if (!child) {
|
||||
child = {
|
||||
lastQueried: undefined,
|
||||
unsubscribe: query(childID, node, (childQueried) => {
|
||||
child!.lastQueried = childQueried;
|
||||
onUpdate();
|
||||
}),
|
||||
};
|
||||
children[childID] = child;
|
||||
}
|
||||
return child.lastQueried as Queried<T> | undefined;
|
||||
}
|
||||
context.query(id, []);
|
||||
|
||||
function resolveValue<T extends JsonValue>(
|
||||
value: T
|
||||
): T extends CoID<CoValue> ? Queried<CoValue> | undefined : T {
|
||||
return (
|
||||
typeof value === "string" && value.startsWith("co_")
|
||||
? getChildLastQueriedOrSubscribe(value as CoID<CoValue>)
|
||||
: value
|
||||
) as T extends CoID<CoValue> ? Queried<CoValue> | undefined : T;
|
||||
}
|
||||
const cleanup = function cleanup() {
|
||||
context.cleanup();
|
||||
} as CleanupCallbackAndUsable;
|
||||
cleanup[Symbol.dispose] = cleanup;
|
||||
|
||||
let lastRootValue: T | undefined;
|
||||
|
||||
function onUpdate() {
|
||||
const rootValue = lastRootValue;
|
||||
|
||||
if (rootValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rootValue instanceof CoMap) {
|
||||
callback(queryMap(rootValue) as Queried<T>);
|
||||
} else if (rootValue instanceof CoList) {
|
||||
callback(queryList(rootValue) as unknown as Queried<T>);
|
||||
} else if (rootValue instanceof CoStream) {
|
||||
if (rootValue.meta?.type === "binary") {
|
||||
// Querying binary string not yet implemented
|
||||
return {}
|
||||
} else {
|
||||
callback(queryStream(rootValue) as unknown as Queried<T>);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return function cleanup() {
|
||||
for (const child of Object.values(children)) {
|
||||
child.unsubscribe();
|
||||
}
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
function queryMap(rootValue: T & CoMap<any, any>) {
|
||||
const mapResult: {
|
||||
[key: string]: any;
|
||||
} = {};
|
||||
// let allChildrenAvailable = true;
|
||||
for (const key of rootValue.keys()) {
|
||||
const value = rootValue.get(key);
|
||||
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (AllReservedQueryProps.includes(key as ReservedQueryProps)) {
|
||||
mapResult.shadowed = mapResult.shadowed || {};
|
||||
mapResult.shadowed[key] = resolveValue(value);
|
||||
} else {
|
||||
mapResult[key] = resolveValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperties(mapResult, {
|
||||
id: { value: rootValue.id },
|
||||
type: { value: "comap" },
|
||||
});
|
||||
|
||||
if (
|
||||
rootValue.meta?.type !== "account" &&
|
||||
rootValue.meta?.type !== "profile"
|
||||
) {
|
||||
Object.defineProperties(mapResult, {
|
||||
edit: {
|
||||
value: (
|
||||
changer: (editable: WriteableCoMap<any, any>) => void
|
||||
) => {
|
||||
rootValue.edit(changer);
|
||||
return rootValue;
|
||||
},
|
||||
},
|
||||
edits: {
|
||||
value: {},
|
||||
},
|
||||
});
|
||||
|
||||
for (const key of rootValue.keys()) {
|
||||
const editorID = rootValue.whoEdited(key);
|
||||
const editor =
|
||||
editorID && getChildLastQueriedOrSubscribe(editorID);
|
||||
mapResult.edits[key] = {
|
||||
by: editor && {
|
||||
id: editorID,
|
||||
isMe: editorID === node.account.id ? true : undefined,
|
||||
profile: editor.profile && {
|
||||
id: editor.profile.id,
|
||||
name: editor.profile.name,
|
||||
},
|
||||
},
|
||||
at: new Date(rootValue.getLastEntry(key)!.at),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperties(mapResult, {
|
||||
meta: { value: rootValue.meta },
|
||||
group: {
|
||||
get() {
|
||||
return rootValue.group;
|
||||
},
|
||||
},
|
||||
core: {
|
||||
get() {
|
||||
return rootValue.core;
|
||||
},
|
||||
},
|
||||
});
|
||||
return mapResult;
|
||||
}
|
||||
|
||||
function queryList(rootValue: T & CoList<any, any>) {
|
||||
const arr: any[] & { [key: string]: any } = rootValue
|
||||
.asArray()
|
||||
.map(resolveValue);
|
||||
|
||||
Object.defineProperties(arr, {
|
||||
type: { value: "colist" },
|
||||
id: { value: rootValue.id },
|
||||
edit: {
|
||||
value: (
|
||||
changer: (editable: WriteableCoList<any, any>) => void
|
||||
) => {
|
||||
rootValue.edit(changer);
|
||||
return rootValue;
|
||||
},
|
||||
},
|
||||
edits: {
|
||||
value: [],
|
||||
},
|
||||
meta: { value: rootValue.meta },
|
||||
group: {
|
||||
get() {
|
||||
return rootValue.group;
|
||||
},
|
||||
},
|
||||
core: {
|
||||
get() {
|
||||
return rootValue.core;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const editorID = rootValue.whoInserted(i);
|
||||
const editor = editorID && getChildLastQueriedOrSubscribe(editorID);
|
||||
arr.edits[i] = {
|
||||
by: editor && {
|
||||
id: editorID,
|
||||
isMe: editorID === node.account.id ? true : undefined,
|
||||
profile: editor.profile && {
|
||||
id: editor.profile.id,
|
||||
name: editor.profile.name,
|
||||
},
|
||||
},
|
||||
at: new Date(rootValue.entries()[i]!.madeAt),
|
||||
};
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function queryStream(rootValue: T & CoStream<any, any>) {
|
||||
const seenAccounts = new Set<AccountID>();
|
||||
|
||||
const perSession = Object.fromEntries(
|
||||
Object.entries(rootValue.items).map(([sessionID, items]) => {
|
||||
const editorID = accountOrAgentIDfromSessionID(
|
||||
sessionID as SessionID
|
||||
);
|
||||
if (isAccountID(editorID)) seenAccounts.add(editorID);
|
||||
const editor =
|
||||
editorID &&
|
||||
(isAccountID(editorID)
|
||||
? getChildLastQueriedOrSubscribe(editorID)
|
||||
: undefined);
|
||||
const lastItem = items[items.length - 1];
|
||||
return [
|
||||
sessionID as SessionID,
|
||||
{
|
||||
last: lastItem && resolveValue(lastItem.item),
|
||||
by: editor && {
|
||||
id: editorID as AccountID,
|
||||
isMe:
|
||||
editorID === node.account.id ? true : undefined,
|
||||
profile: editor.profile && {
|
||||
id: editor.profile.id,
|
||||
name: editor.profile.name,
|
||||
},
|
||||
},
|
||||
at: lastItem && new Date(lastItem.madeAt),
|
||||
all: items.map((item) => ({
|
||||
value: item.item && resolveValue(item.item),
|
||||
at: new Date(item.madeAt),
|
||||
})),
|
||||
} satisfies QueriedCoStreamItems<JsonValue>,
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
const perAccount = Object.fromEntries(
|
||||
[...seenAccounts.values()].map((accountID) => {
|
||||
const itemsFromAllMatchingSessions = Object.entries(perSession)
|
||||
.flatMap(([sessionID, sessionItems]) =>
|
||||
sessionID.startsWith(accountID) ? sessionItems.all : []
|
||||
)
|
||||
.sort((a, b) => {
|
||||
return a.at.getTime() - b.at.getTime();
|
||||
});
|
||||
const editor = getChildLastQueriedOrSubscribe(accountID);
|
||||
const lastItem =
|
||||
itemsFromAllMatchingSessions[
|
||||
itemsFromAllMatchingSessions.length - 1
|
||||
];
|
||||
|
||||
return [
|
||||
accountID,
|
||||
{
|
||||
last: lastItem?.value,
|
||||
by: editor && {
|
||||
id: accountID,
|
||||
isMe:
|
||||
accountID === node.account.id
|
||||
? true
|
||||
: undefined,
|
||||
profile: editor.profile && {
|
||||
id: editor.profile.id,
|
||||
name: editor.profile.name,
|
||||
},
|
||||
},
|
||||
at: lastItem && new Date(lastItem.at),
|
||||
all: itemsFromAllMatchingSessions,
|
||||
} satisfies QueriedCoStreamItems<JsonValue>,
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
const me = isAccountID(node.account.id)
|
||||
? perAccount[node.account.id]
|
||||
: undefined;
|
||||
|
||||
const streamResult: QueriedCoStream<AnyCoStream> = {
|
||||
type: "costream",
|
||||
id: rootValue.id,
|
||||
perSession,
|
||||
perAccount,
|
||||
me,
|
||||
meta: rootValue.meta,
|
||||
get group() {
|
||||
return rootValue.group;
|
||||
},
|
||||
get core() {
|
||||
return rootValue.core;
|
||||
},
|
||||
edit: (
|
||||
changer: (editable: WriteableCoStream<any, any>) => void
|
||||
) => {
|
||||
rootValue.edit(changer);
|
||||
return rootValue;
|
||||
},
|
||||
};
|
||||
|
||||
return streamResult;
|
||||
}
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@@ -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 "./jsonStringify.js";
|
||||
|
||||
export type CoValueKnownState = {
|
||||
id: RawCoID;
|
||||
@@ -67,6 +66,7 @@ export interface Peer {
|
||||
incoming: ReadableStream<SyncMessage>;
|
||||
outgoing: WritableStream<SyncMessage>;
|
||||
role: "peer" | "server" | "client";
|
||||
delayOnError?: number;
|
||||
}
|
||||
|
||||
export interface PeerState {
|
||||
@@ -76,6 +76,7 @@ export interface PeerState {
|
||||
incoming: ReadableStream<SyncMessage>;
|
||||
outgoing: WritableStreamDefaultWriter<SyncMessage>;
|
||||
role: "peer" | "server" | "client";
|
||||
delayOnError?: number;
|
||||
}
|
||||
|
||||
export function combinedKnownStates(
|
||||
@@ -224,7 +225,7 @@ export class SyncManager {
|
||||
peer.optimisticKnownStates[id] || emptyKnownState(id);
|
||||
|
||||
const sendPieces = async () => {
|
||||
for (const [i, piece] of newContentPieces.entries()) {
|
||||
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)
|
||||
@@ -254,6 +255,7 @@ export class SyncManager {
|
||||
outgoing: peer.outgoing.getWriter(),
|
||||
toldKnownState: new Set(),
|
||||
role: peer.role,
|
||||
delayOnError: peer.delayOnError,
|
||||
};
|
||||
this.peers[peer.id] = peerState;
|
||||
|
||||
@@ -284,6 +286,7 @@ export class SyncManager {
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
new Date(),
|
||||
`Error reading from peer ${peer.id}, handling msg`,
|
||||
JSON.stringify(msg, (k, v) =>
|
||||
k === "changes" || k === "encryptedChanges"
|
||||
@@ -292,6 +295,11 @@ export class SyncManager {
|
||||
),
|
||||
e
|
||||
);
|
||||
if (peerState.delayOnError) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, peerState.delayOnError);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -505,7 +513,11 @@ export class SyncManager {
|
||||
console.error(
|
||||
"Failed to add transactions",
|
||||
msg.id,
|
||||
newTransactions
|
||||
JSON.stringify(newTransactions, (k, v) =>
|
||||
k === "changes" || k === "encryptedChanges"
|
||||
? v.slice(0, 20) + "..."
|
||||
: v
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { newRandomSessionID } from "../coValueCore.js";
|
||||
import { cojsonReady } from "../index.js";
|
||||
import { LocalNode } from "../node.js";
|
||||
import { LocalNode } from "../localNode.js";
|
||||
import { connectedPeers } from "../streamUtils.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -9,7 +9,7 @@ beforeEach(async () => {
|
||||
|
||||
test("Can create a node while creating a new account with profile", async () => {
|
||||
const { node, accountID, accountSecret, sessionID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
LocalNode.withNewlyCreatedAccount({ name: "Hermes Puggington" });
|
||||
|
||||
expect(node).not.toBeNull();
|
||||
expect(accountID).not.toBeNull();
|
||||
@@ -19,14 +19,12 @@ 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 () => {
|
||||
const { node, accountID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
const { node, accountID } = LocalNode.withNewlyCreatedAccount({
|
||||
name: "Hermes Puggington",
|
||||
});
|
||||
|
||||
const group = await node.createGroup();
|
||||
expect(group).not.toBeNull();
|
||||
@@ -39,12 +37,12 @@ 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 () => {
|
||||
const { node, accountID, accountSecret } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
LocalNode.withNewlyCreatedAccount({ name: "Hermes Puggington" });
|
||||
|
||||
const group = await node.createGroup();
|
||||
expect(group).not.toBeNull();
|
||||
@@ -55,16 +53,20 @@ test("Can create account with one node, and then load it on another", async () =
|
||||
expect(edit.get("foo")).toEqual("bar");
|
||||
});
|
||||
|
||||
const [node1asPeer, node2asPeer] = connectedPeers("node1", "node2", {trace: true, peer1role: "server", peer2role: "client"});
|
||||
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(
|
||||
const node2 = await LocalNode.withLoadedAccount({
|
||||
accountID,
|
||||
accountSecret,
|
||||
newRandomSessionID(accountID),
|
||||
[node1asPeer]
|
||||
);
|
||||
sessionID: newRandomSessionID(accountID),
|
||||
peersToLoadFrom: [node1asPeer],
|
||||
});
|
||||
|
||||
const map2 = await node2.load(map.id);
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 "../node.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,12 +339,19 @@ 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");
|
||||
editable.startBinaryStream(
|
||||
{ mimeType: "text/plain", fileName: "test.txt" },
|
||||
"trusting"
|
||||
);
|
||||
expect(editable.getBinaryChunks(true)).toEqual({
|
||||
mimeType: "text/plain",
|
||||
fileName: "test.txt",
|
||||
@@ -426,16 +396,23 @@ test("When adding large transactions (small fraction of MAX_RECOMMENDED_TX_SIZE)
|
||||
|
||||
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");
|
||||
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);
|
||||
const chunk = new Uint8Array(MAX_RECOMMENDED_TX_SIZE / 3 + 100);
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.pushBinaryStreamChunk(chunk, "trusting");
|
||||
@@ -461,14 +438,26 @@ test("When adding large transactions (small fraction of MAX_RECOMMENDED_TX_SIZE)
|
||||
expect(sessionEntry.signatureAfter[10]).not.toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[11]).not.toBeDefined();
|
||||
|
||||
const newContent = coValue.newContentSince({id: coValue.id, header: false, sessions: {}})!;
|
||||
const newContent = coValue.newContentSince({
|
||||
id: coValue.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
})!;
|
||||
|
||||
expect(newContent.length).toEqual(5)
|
||||
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);
|
||||
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", () => {
|
||||
@@ -483,12 +472,19 @@ test("When adding large transactions (bigger than MAX_RECOMMENDED_TX_SIZE), we s
|
||||
|
||||
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");
|
||||
editable.startBinaryStream(
|
||||
{ mimeType: "text/plain", fileName: "test.txt" },
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const chunk = new Uint8Array(MAX_RECOMMENDED_TX_SIZE + 100);
|
||||
@@ -511,14 +507,24 @@ test("When adding large transactions (bigger than MAX_RECOMMENDED_TX_SIZE), we s
|
||||
expect(sessionEntry.signatureAfter[3]).toBeDefined();
|
||||
expect(sessionEntry.signatureAfter[4]).not.toBeDefined();
|
||||
|
||||
const newContent = coValue.newContentSince({id: coValue.id, header: false, sessions: {}})!;
|
||||
const newContent = coValue.newContentSince({
|
||||
id: coValue.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
})!;
|
||||
|
||||
expect(newContent.length).toEqual(5)
|
||||
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);
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user