Compare commits
34 Commits
cojson@0.1
...
cojson@0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33c2705329 | ||
|
|
4873a634a4 | ||
|
|
edb43cd070 | ||
|
|
b128a2d6f7 | ||
|
|
27abcb4f6f | ||
|
|
e9b41c4344 | ||
|
|
d93b376e4b | ||
|
|
aeb38eb7d5 | ||
|
|
07bffb5050 | ||
|
|
012bd43865 | ||
|
|
ffc1181b81 | ||
|
|
4ca5e258b5 | ||
|
|
2255c824b7 | ||
|
|
8ed59e40e9 | ||
|
|
03b34b4b66 | ||
|
|
53c93f6a0b | ||
|
|
4af7f25eab | ||
|
|
6d6e8a0e28 | ||
|
|
4a617c8323 | ||
|
|
eaed275a79 | ||
|
|
01fdcaed34 | ||
|
|
7aeb1a789b | ||
|
|
a00649fa29 | ||
|
|
764954c727 | ||
|
|
b0ec93eb3a | ||
|
|
4dd226bc95 | ||
|
|
1692340856 | ||
|
|
fbda78f908 | ||
|
|
61e9f6afad | ||
|
|
246bbb119d | ||
|
|
80054515c9 | ||
|
|
f9486a82c3 | ||
|
|
d0babab822 | ||
|
|
fe1092ccf6 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
yarn-error.log
|
||||
lerna-debug.log
|
||||
lerna-debug.log
|
||||
docsTmp
|
||||
266
README.md
266
README.md
@@ -47,7 +47,7 @@ The best example of Jazz is currently the Todo List app.
|
||||
- Live version: https://example-todo.jazz.tools
|
||||
- Source code: [`./examples/todo`](./examples/todo). See the README there for a walk-through and running instructions.
|
||||
|
||||
# API Reference
|
||||
# Documentation
|
||||
|
||||
Note: Since it's early days, this is the only source of documentation so far.
|
||||
|
||||
@@ -55,11 +55,11 @@ If you want to build something with Jazz, [join the Jazz Discord](https://discor
|
||||
|
||||
## Overview: Main Packages
|
||||
|
||||
**`cojson`**
|
||||
**`cojson`** → [DOCS](./DOCS.md#cojson)
|
||||
|
||||
A library implementing abstractions and protocols for "Collaborative JSON". This will soon be standardized and forms the basis of secure telepathic data.
|
||||
|
||||
**`jazz-react`**
|
||||
**`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.
|
||||
|
||||
@@ -70,7 +70,7 @@ Provides you with everything you need to build react apps around CoJSON, includi
|
||||
|
||||
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`**
|
||||
**`jazz-browser`** → [DOCS](./DOCS.md#jazz-browser)
|
||||
|
||||
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.
|
||||
|
||||
@@ -79,260 +79,4 @@ framework-agnostic primitives that allow you to use CoJSON in the browser. Used
|
||||
**`jazz-storage-indexeddb`**
|
||||
|
||||
Provides local, offline-capable persistence. Included and enabled in `jazz-react` by default.
|
||||
</small>
|
||||
|
||||
## `CoJSON`
|
||||
|
||||
CoJSON is the core implementation of secure telepathic data. It provides abstractions for Collaborative JSON values ("`CoValues`"), groups for permission management and a protocol for syncing between nodes. Our goal is to standardise CoJSON soon and port it to other languages and platforms.
|
||||
|
||||
---
|
||||
|
||||
### `LocalNode`
|
||||
|
||||
A `LocalNode` represents a local view of a set of loaded `CoValue`s, from the perspective of a particular account (or primitive cryptographic agent).
|
||||
|
||||
A `LocalNode` can have peers that it syncs to, for example some form of local persistence, or a sync server, such as `sync.jazz.tools` (Jazz Global Mesh).
|
||||
|
||||
You typically get hold of a `LocalNode` using `jazz-react`'s `useJazz()`:
|
||||
|
||||
```typescript
|
||||
const { localNode } = useJazz();
|
||||
```
|
||||
|
||||
#### `LocalNode.load(id)`
|
||||
```typescript
|
||||
load<T extends ContentType>(id: CoID<T>): Promise<T>
|
||||
```
|
||||
|
||||
Loads a CoValue's content, syncing from peers as necessary and resolving the returned promise once a first version has been loaded. See `ContentType.subscribe()` and `useTelepathicData` for listening to subsequent updates to the CoValue.
|
||||
|
||||
#### `LocalNode.loadProfile(id)`
|
||||
```typescript
|
||||
loadProfile(accountID: AccountID): Promise<Profile>
|
||||
```
|
||||
|
||||
Loads a profile associated with an account. `Profile` is at least a `CoMap<{string: name}>`, but might contain other, app-specific properties.
|
||||
|
||||
#### `LocalNode.acceptInvite(valueOrGroup, inviteSecret)`
|
||||
```typescript
|
||||
acceptInvite<T extends ContentType>(
|
||||
valueOrGroup: CoID<T>,
|
||||
inviteSecret: InviteSecret
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
Accepts an invite for a group, or infers the group if given the `CoID` of a value owned by that group. Resolves upon successful joining of that group, at which point you should be able to `LocalNode.load` the value.
|
||||
|
||||
Invites can be created with `Group.createInvite(role)`.
|
||||
|
||||
#### `LocalNode.createGroup()`
|
||||
```typescript
|
||||
createGroup(): Group
|
||||
```
|
||||
|
||||
Creates a new group (with the current account as the group's first admin).
|
||||
|
||||
---
|
||||
|
||||
### `Group`
|
||||
|
||||
A CoJSON group manages permissions of its members. A `Group` object exposes those capabilities 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)
|
||||
|
||||
#### `Group.id`
|
||||
|
||||
Returns the `CoID` of the `Group`.
|
||||
|
||||
#### `Group.roleOf(accountID)`
|
||||
|
||||
```typescript
|
||||
roleOf(accountID: AccountID): "reader" | "writer" | "admin" | undefined
|
||||
```
|
||||
|
||||
Returns the current role of a given account.
|
||||
|
||||
#### `Group.myRole()`
|
||||
|
||||
```typescript
|
||||
myRole(accountID: AccountID): "reader" | "writer" | "admin" | undefined
|
||||
```
|
||||
|
||||
Returns the role of the current account in the group.
|
||||
|
||||
#### `Group.addMember(accountID, role)`
|
||||
|
||||
```typescript
|
||||
addMember(
|
||||
accountID: AccountIDOrAgentID,
|
||||
role: "reader" | "writer" | "admin"
|
||||
)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
#### `Group.createInvite(role)`
|
||||
|
||||
```typescript
|
||||
createInvite(role: "reader" | "writer" | "admin"): InviteSecret
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
#### `Group.removeMember(accountID)`
|
||||
|
||||
```typescript
|
||||
removeMember(accountID: AccountID)
|
||||
```
|
||||
|
||||
Strips the specified member of all roles (preventing future writes) and rotates the read encryption key for that group (preventing reads of new content, including in covalues owned by this group)
|
||||
|
||||
#### `Group.createMap(meta?)`
|
||||
```typescript
|
||||
createMap<
|
||||
M extends { [key: string]: JsonValue },
|
||||
Meta extends JsonObject | null = null
|
||||
>(meta?: Meta): CoMap<M, Meta>
|
||||
```
|
||||
|
||||
Creates a new `CoMap` within this group, with the specified inner content type `M` and optional static metadata.
|
||||
|
||||
#### `Group.createList(meta?)` (coming soon)
|
||||
#### `Group.createStream(meta?)` (coming soon)
|
||||
#### `Group.createStatic(meta)` (coming soon)
|
||||
|
||||
---
|
||||
### `CoValue` ContentType: `CoMap`
|
||||
|
||||
```typescript
|
||||
class CoMap<
|
||||
M extends { [key: string]: JsonValue; },
|
||||
Meta extends JsonObject | null = null,
|
||||
>
|
||||
```
|
||||
|
||||
#### `CoMap.id`
|
||||
|
||||
```typescript
|
||||
id: CoID<CoMap<M, Meta>>
|
||||
```
|
||||
|
||||
Returns the CoMap's (precisely typed) `CoID`
|
||||
|
||||
#### `CoMap.keys()`
|
||||
|
||||
```typescript
|
||||
keys(): (keyof M & string)[]
|
||||
```
|
||||
|
||||
#### `CoMap.get(key)`
|
||||
|
||||
```typescript
|
||||
get<K extends keyof M>(key: K): M[K] | undefined
|
||||
```
|
||||
|
||||
Returns the current value for the given key.
|
||||
|
||||
#### `CoMap.getLastEditor(key)`
|
||||
|
||||
```typescript
|
||||
getLastEditor<K extends keyof M>(key: K): AccountID | undefined
|
||||
```
|
||||
|
||||
Returns the accountID of the last account to modify the value for the given key.
|
||||
|
||||
#### `CoMap.toJSON()`
|
||||
|
||||
```typescript
|
||||
toJSON(): JsonObject
|
||||
```
|
||||
|
||||
Returns a JSON representation of the state of the CoMap.
|
||||
|
||||
#### `CoMap.subscribe(listener)`
|
||||
|
||||
```typescript
|
||||
subscribe(
|
||||
listener: (coMap: CoMap<M, Meta>) => void
|
||||
): () => void
|
||||
```
|
||||
Lets you subscribe to future updates to this CoMap (whether made locally or by other users). Takes a listener function that will be called with the current state for each update. Returns an unsubscribe function.
|
||||
|
||||
Used internally by `useTelepathicData()` for reactive updates on changes to a `CoMap`.
|
||||
|
||||
#### `CoMap.edit(editable => {...})`
|
||||
|
||||
```typescript
|
||||
edit(changer: (editable: WriteableCoMap<M, Meta>) => void): CoMap<M, Meta>
|
||||
```
|
||||
|
||||
Lets you apply edits to a `CoMap`, inside the changer callback, which receives a `WriteableCoMap`. A `WritableCoMap` has all the same methods as a `CoMap`, but all edits made to it with `set` or `delete` are reflected in it immediately - so it behaves mutably, whereas a `CoMap` is always immutable (you need to use `subscribe` to receive new versions of it).
|
||||
|
||||
```typescript
|
||||
export class WriteableCoMap<
|
||||
M extends { [key: string]: JsonValue; },
|
||||
Meta extends JsonObject | null = null,
|
||||
> extends CoMap<M, Meta>
|
||||
```
|
||||
|
||||
#### `WritableCoMap.set(key, value)`
|
||||
|
||||
```typescript
|
||||
set<K extends keyof M>(
|
||||
key: K,
|
||||
value: M[K],
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
#### `WritableCoMap.delete(key)`
|
||||
|
||||
```typescript
|
||||
delete<K extends keyof M>(
|
||||
key: K,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
### `CoValue` ContentType: `CoList` (not yet implemented)
|
||||
|
||||
---
|
||||
### `CoValue` ContentType: `CoStram` (not yet implemented)
|
||||
|
||||
---
|
||||
### `CoValue` ContentType: `Static` (not yet implemented)
|
||||
---
|
||||
|
||||
## `jazz-react`
|
||||
---
|
||||
### `<WithJazz>`
|
||||
|
||||
Not yet documented, see [`examples/todo`](./examples/todo/) for now
|
||||
|
||||
---
|
||||
### `useJazz()`
|
||||
|
||||
Not yet documented, see [`examples/todo`](./examples/todo/) for now
|
||||
|
||||
---
|
||||
### `useTelepathicData(coID)`
|
||||
|
||||
Not yet documented, see [`examples/todo`](./examples/todo/) for now
|
||||
|
||||
---
|
||||
### `useProfile(accountID)`
|
||||
|
||||
Not yet documented, see [`examples/todo`](./examples/todo/) for now
|
||||
</small>
|
||||
@@ -2,343 +2,64 @@
|
||||
|
||||
Live version: https://example-todo.jazz.tools
|
||||
|
||||
More comprehensive guide coming soon, but these are the most important bits, with explanations:
|
||||
## Installing & running the example locally
|
||||
|
||||
From `./src/main.tsx`
|
||||
|
||||
```typescript
|
||||
// ...
|
||||
|
||||
import { WithJazz } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
// ...
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<div className="flex items-center gap-2 justify-center mt-5">
|
||||
<img src="jazz-logo.png" className="h-5" /> Jazz Todo List
|
||||
Example
|
||||
</div>
|
||||
<WithJazz
|
||||
auth={LocalAuth({
|
||||
appName: "Jazz Todo List Example",
|
||||
Component: PrettyAuthComponent,
|
||||
})}
|
||||
>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
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 shows how to use the top-level component `<WithJazz/>`, which provides the rest of the app with a `LocalNode` (used through `useJazz` later), based on `LocalAuth` that uses Passkeys to store a user's account secret - no backend needed.
|
||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
||||
|
||||
Let's move on to the main app code.
|
||||
Install dependencies:
|
||||
|
||||
---
|
||||
|
||||
From `./src/App.tsx`
|
||||
|
||||
```typescript
|
||||
// ...
|
||||
|
||||
import { CoMap, CoID, AccountID } from "cojson";
|
||||
import {
|
||||
consumeInviteLinkFromWindowLocation,
|
||||
useJazz,
|
||||
useProfile,
|
||||
useTelepathicState,
|
||||
createInviteLink
|
||||
} from "jazz-react";
|
||||
|
||||
// ...
|
||||
|
||||
type TaskContent = { done: boolean; text: string };
|
||||
type Task = CoMap<TaskContent>;
|
||||
|
||||
type TodoListContent = {
|
||||
title: string;
|
||||
// other keys form a set of task IDs
|
||||
[taskId: CoID<Task>]: true;
|
||||
};
|
||||
type TodoList = CoMap<TodoListContent>;
|
||||
|
||||
// ...
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
First, we define our main data model of tasks and todo lists, using CoJSON's collaborative map type, `CoMap`. We reference CoMaps of individual tasks by using them as keys inside the `TodoList` CoMap - as a makeshift solution until `CoList` is implemented.
|
||||
Start the dev server:
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
// ...
|
||||
|
||||
export default function App() {
|
||||
const [listId, setListId] = useState<CoID<TodoList>>();
|
||||
|
||||
const { localNode, logOut } = useJazz();
|
||||
|
||||
useEffect(() => {
|
||||
const listener = async () => {
|
||||
const acceptedInvitation =
|
||||
await consumeInviteLinkFromWindowLocation(localNode);
|
||||
|
||||
if (acceptedInvitation) {
|
||||
setListId(acceptedInvitation.valueID as CoID<TodoList>);
|
||||
window.location.hash = acceptedInvitation.valueID;
|
||||
return;
|
||||
}
|
||||
|
||||
setListId(window.location.hash.slice(1) as CoID<TodoList>);
|
||||
};
|
||||
window.addEventListener("hashchange", listener);
|
||||
listener();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", listener);
|
||||
};
|
||||
}, [localNode]);
|
||||
|
||||
const createList = useCallback(
|
||||
(title: string) => {
|
||||
const listGroup = localNode.createGroup();
|
||||
const list = listGroup.createMap<TodoListContent>();
|
||||
|
||||
list.edit((list) => {
|
||||
list.set("title", title);
|
||||
});
|
||||
|
||||
window.location.hash = list.id;
|
||||
},
|
||||
[localNode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
{listId ? (
|
||||
<TodoListComponent listId={listId} />
|
||||
) : (
|
||||
<SubmittableInput
|
||||
onSubmit={createList}
|
||||
label="Create New List"
|
||||
placeholder="New list title"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.hash = "";
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`<App>` is the main app component, handling client-side routing based on the CoValue ID (`CoID`) of our `TodoList`, stored in the URL hash - which can also contain invite links, which we intercept and use with `consumeInviteLinkFromWindowLocation`.
|
||||
## Structure
|
||||
|
||||
`createList` is the first time we see CoJSON in action: using our `localNode` (which we got from `useJazz`), we first create a group for a new todo list (which allows us to set permissions later). Then, within that group, we create a new `CoMap<TodoListContent>` with `listGroup.createMap()`.
|
||||
- [`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
|
||||
|
||||
We immediately start editing the created `list`. Within the edit callback, we can use the `set` function, to collaboratively set the key `title` to the initial title provided to `createList`.
|
||||
## Walkthrough
|
||||
|
||||
If we have a current `listId` set, we render `<TodoListComponent>` with it, which we'll see next.
|
||||
### Main parts
|
||||
|
||||
If we have no `listId` set, the user can use the displayed creation input to create (and open) their first list.
|
||||
- The top-level provider `<WithJazz/>`: [`src/0_main.tsx`](./src/0_main.tsx)
|
||||
|
||||
---
|
||||
- Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
|
||||
|
||||
```typescript
|
||||
export function TodoListComponent({ listId }: { listId: CoID<TodoList> }) {
|
||||
const list = useTelepathicState(listId);
|
||||
- Creating todo projects & routing in `<App/>`: [`src/2_App.tsx`](./src/2_App.tsx)
|
||||
|
||||
const createTask = (text: string) => {
|
||||
if (!list) return;
|
||||
const task = list.coValue.getGroup().createMap<TaskContent>();
|
||||
- Reactively rendering a todo project as a table, adding and editing tasks: [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx)
|
||||
|
||||
task.edit((task) => {
|
||||
task.set("text", text);
|
||||
task.set("done", false);
|
||||
});
|
||||
### Helpers
|
||||
|
||||
list.edit((list) => {
|
||||
list.set(task.id, true);
|
||||
});
|
||||
};
|
||||
- Getting user profiles in `<NameBadge/>`: [`src/components/NameBadge.tsx`](./src/components/NameBadge.tsx)
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{list?.get("title") ? (
|
||||
<>
|
||||
{list.get("title")}{" "}
|
||||
<span className="text-sm">({list.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</h1>
|
||||
{list && <InviteButton list={list} />}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Done</TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list &&
|
||||
list
|
||||
.keys()
|
||||
.filter((key): key is CoID<Task> =>
|
||||
key.startsWith("co_")
|
||||
)
|
||||
.map((taskId) => (
|
||||
<TaskRow key={taskId} taskId={taskId} />
|
||||
))}
|
||||
<TableRow key="new">
|
||||
<TableCell>
|
||||
<Checkbox className="mt-1" disabled />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubmittableInput
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={!list}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
- (not yet commented) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
|
||||
|
||||
Here in `<TodoListComponent>`, we use `useTelepathicData()` for the first time, in this case to load the CoValue for our `TodoList` and to reactively subscribe to updates to its content - whether we create edits locally, load persisted data, or receive sync updates from other devices or participants!
|
||||
|
||||
`createTask` is similar to `createList` we saw earlier, creating a new CoMap for a new task, and then adding it as a key to our `TodoList`.
|
||||
|
||||
As you can see, we iterate over the keys of `TodoList` and for those that look like `CoID`s (they always start with `co_`), we render a `<TaskRow>`.
|
||||
|
||||
Below all tasks, we render a simple input for adding a task.
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
function TaskRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
const task = useTelepathicState(taskId);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
className="mt-1"
|
||||
checked={task?.get("done")}
|
||||
onCheckedChange={(checked) => {
|
||||
task?.edit((task) => {
|
||||
task.set("done", !!checked);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
<span className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text") || <Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />}
|
||||
</span>
|
||||
<NameBadge accountID={task?.getLastEditor("text")} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`<TaskRow>` uses `useTelepathicState()` as well, to granularly load and subscribe to changes for that particular task (the only thing we let the user change is the "done" status).
|
||||
|
||||
We also use a `<NameBadge>` helper component to render the name of the author of the task, which we get by using the collaboration feature `getLastEditor(key)` on our `Task` CoMap, which returns the accountID of the last account that changed a given key in the CoMap.
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
function NameBadge({ accountID }: { accountID?: AccountID }) {
|
||||
const profile = useProfile(accountID);
|
||||
|
||||
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
|
||||
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
|
||||
|
||||
return (
|
||||
profile?.get("name") && <span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={{
|
||||
color: theme == "light" ? darkColor : brightColor,
|
||||
background: theme == "light" ? brightColor : darkColor,
|
||||
}}
|
||||
>
|
||||
{profile.get("name")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`<NameBadge>` uses `useProfile(accountID)`, which is a shorthand for loading an account's profile (which is always a `CoMap<{name: string}>`, but might have app-specific additional properties).
|
||||
|
||||
In our case, we just display the profile name (which, by the way, is set by the `LocalAuth` provider when we first create an account).
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
function InviteButton({ list }: { list: TodoList }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
list.coValue.getGroup().myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (list && !inviteLink) {
|
||||
inviteLink = createInviteLink(list, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
description: "Copied invite link to clipboard!",
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Last, we have a look at the `<InviteButton>` component, which we use inside `<TodoListComponent>`. It only becomes visible when the current user is an admin in the `TodoList`'s group. You can see how we can create an invite link using `createInviteLink(coValue, role)` that allows anyone who has it to join the group as a specified role (here, as a writer).
|
||||
|
||||
---
|
||||
- (not yet commented) `location.hash`-based routing and accepting invite links with `useSimpleHashRouterThatAcceptsInvites()` in [`src/router.ts`](./src/router.ts)
|
||||
|
||||
This is the whole Todo List app!
|
||||
|
||||
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.
|
||||
## 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/0_main.tsx](./src/0_main.tsx).
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
"components": "@/basicComponents",
|
||||
"utils": "@/basicComponents/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/0_main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.25",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,14 +16,14 @@
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.1.6",
|
||||
"jazz-react-auth-local": "^0.1.6",
|
||||
"lucide-react": "^0.265.0",
|
||||
"jazz-react": "^0.1.11",
|
||||
"jazz-react-auth-local": "^0.1.11",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uniqolor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
38
examples/todo/src/0_main.tsx
Normal file
38
examples/todo/src/0_main.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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 */
|
||||
28
examples/todo/src/1_types.ts
Normal file
28
examples/todo/src/1_types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { CoMap, CoList, CoID } 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:
|
||||
* - 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; }>;
|
||||
|
||||
/** A collaborative, ordered list of task references */
|
||||
export type ListOfTasks = CoList<CoID<Task>>;
|
||||
|
||||
/** Our top level object: a project with a title, referencing a list of tasks */
|
||||
export type TodoProject = CoMap<{
|
||||
title: string;
|
||||
tasks: CoID<ListOfTasks>;
|
||||
}>;
|
||||
|
||||
/** Walkthrough: Continue with ./2_App.tsx */
|
||||
78
examples/todo/src/2_App.tsx
Normal file
78
examples/todo/src/2_App.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { TodoProject, ListOfTasks } from "./1_types";
|
||||
|
||||
import { SubmittableInput, Button } from "./basicComponents";
|
||||
|
||||
import { useSimpleHashRouterThatAcceptsInvites } from "./router";
|
||||
import { TodoTable } from "./3_TodoTable";
|
||||
|
||||
/** Walkthrough: Creating todo projects & routing in `<App/>`
|
||||
*
|
||||
* <App> is the main app component, handling client-side routing based
|
||||
* on the CoValue ID (CoID) of our TodoProject, stored in the URL hash
|
||||
* - which can also contain invite links.
|
||||
*/
|
||||
|
||||
export default function App() {
|
||||
// A `LocalNode` represents a local view of loaded & created CoValues.
|
||||
// It is associated with a current user account, which will determine
|
||||
// access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
|
||||
const { localNode, logOut } = useJazz();
|
||||
|
||||
// This sets up routing and accepting invites, skip for now
|
||||
const [currentProjectId, navigateToProjectId] =
|
||||
useSimpleHashRouterThatAcceptsInvites<TodoProject>(localNode);
|
||||
|
||||
const createProject = useCallback(
|
||||
(title: string) => {
|
||||
if (!title) return;
|
||||
|
||||
// To create a new todo project, we first create a `Group`,
|
||||
// which is a scope for defining access rights (reader/writer/admin)
|
||||
// of its members, which will apply to all CoValues owned by that group.
|
||||
const projectGroup = localNode.createGroup();
|
||||
|
||||
// Then we create an empty todo project and list of tasks within that group.
|
||||
const project = projectGroup.createMap<TodoProject>();
|
||||
const tasks = projectGroup.createList<ListOfTasks>();
|
||||
|
||||
// We edit the todo project to initialise it.
|
||||
// Inside the `.edit` callback we can mutate a CoValue
|
||||
project.edit((project) => {
|
||||
project.set("title", title);
|
||||
project.set("tasks", tasks.id);
|
||||
});
|
||||
|
||||
navigateToProjectId(project.id);
|
||||
},
|
||||
[localNode, navigateToProjectId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
{currentProjectId ? (
|
||||
<TodoTable projectId={currentProjectId} />
|
||||
) : (
|
||||
<SubmittableInput
|
||||
onSubmit={createProject}
|
||||
label="Create New Project"
|
||||
placeholder="New project title"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigateToProjectId(undefined);
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: continue with ./3_TodoTable.tsx */
|
||||
162
examples/todo/src/3_TodoTable.tsx
Normal file
162
examples/todo/src/3_TodoTable.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { CoID } from "cojson";
|
||||
import { useTelepathicState } from "jazz-react";
|
||||
|
||||
import { TodoProject, Task } from "./1_types";
|
||||
|
||||
import {
|
||||
Checkbox,
|
||||
SubmittableInput,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "./basicComponents";
|
||||
|
||||
import { InviteButton } from "./components/InviteButton";
|
||||
import { NameBadge } from "./components/NameBadge";
|
||||
|
||||
/** Walkthrough: Reactively rendering a todo project as a table,
|
||||
* adding and editing tasks
|
||||
*
|
||||
* Here in `<TodoTable/>`, we use `useTelepathicData()` for the first time,
|
||||
* in this case to load the CoValue for our `TodoProject` as well as
|
||||
* the `ListOfTasks` referenced in it.
|
||||
*/
|
||||
|
||||
export function TodoTable({ projectId }: { projectId: CoID<TodoProject> }) {
|
||||
// `useTelepathicData()` reactively subscribes to updates to a CoValue's
|
||||
// content - whether we create edits locally, load persisted data, or receive
|
||||
// sync updates from other devices or participants!
|
||||
const project = useTelepathicState(projectId);
|
||||
const projectTasks = useTelepathicState(project?.get("tasks"));
|
||||
|
||||
// `createTask` is similar to `createProject` we saw earlier, creating a new CoMap
|
||||
// for a new task (in the same group as the list of tasks/the project), and then
|
||||
// adding it as an item to the project's list of tasks.
|
||||
const createTask = useCallback(
|
||||
(text: string) => {
|
||||
if (!projectTasks || !text) return;
|
||||
const task = projectTasks.group.createMap<Task>();
|
||||
|
||||
task.edit((task) => {
|
||||
task.set("text", text);
|
||||
task.set("done", false);
|
||||
});
|
||||
|
||||
projectTasks.edit((projectTasks) => {
|
||||
projectTasks.push(task.id);
|
||||
});
|
||||
},
|
||||
[projectTasks]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{
|
||||
// This is how we can access properties from the project,
|
||||
// accounting for the fact that it might not be loaded yet
|
||||
project?.get("title") ? (
|
||||
<>
|
||||
{project.get("title")}{" "}
|
||||
<span className="text-sm">({project.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)
|
||||
}
|
||||
</h1>
|
||||
<InviteButton list={project} />
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Done</TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{
|
||||
// Here, we iterate over the items of our `ListOfTasks`
|
||||
// and render a `<TaskRow>` for each.
|
||||
|
||||
projectTasks?.map((taskId: CoID<Task>) => (
|
||||
<TaskRow key={taskId} taskId={taskId} />
|
||||
))
|
||||
}
|
||||
<NewTaskInputRow
|
||||
createTask={createTask}
|
||||
disabled={!project}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
// `<TaskRow/>` uses `useTelepathicState()` as well, to granularly load and
|
||||
// subscribe to changes for that particular task.
|
||||
const task = useTelepathicState(taskId);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
className="mt-1"
|
||||
checked={task?.get("done")}
|
||||
onCheckedChange={(checked) => {
|
||||
// (the only thing we let the user change is the "done" status)
|
||||
task?.edit((task) => {
|
||||
task.set("done", !!checked);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
<span className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text") || (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</span>
|
||||
{/* We also use a `<NameBadge/>` helper component to render the name
|
||||
of the author of the task. We get the author by using the collaboration
|
||||
feature `whoEdited(key)` on our `Task` CoMap, which returns the accountID
|
||||
of the last account that changed a given key in the CoMap. */}
|
||||
<NameBadge accountID={task?.whoEdited("text")} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTaskInputRow({
|
||||
createTask,
|
||||
disabled,
|
||||
}: {
|
||||
createTask: (text: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox className="mt-1" disabled />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubmittableInput
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { CoMap, CoID, AccountID } from "cojson";
|
||||
import {
|
||||
consumeInviteLinkFromWindowLocation,
|
||||
useJazz,
|
||||
useProfile,
|
||||
useTelepathicState,
|
||||
createInviteLink,
|
||||
} from "jazz-react";
|
||||
|
||||
import { SubmittableInput } from "./components/SubmittableInput";
|
||||
import { useToast } from "./components/ui/use-toast";
|
||||
import { Skeleton } from "./components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import uniqolor from "uniqolor";
|
||||
import QRCode from "qrcode";
|
||||
import { CoList } from "cojson/dist/contentTypes/coList";
|
||||
|
||||
type Task = CoMap<{ done: boolean; text: string }>;
|
||||
|
||||
type ListOfTasks = CoList<CoID<Task>>;
|
||||
|
||||
type TodoList = CoMap<{
|
||||
title: string;
|
||||
tasks: CoID<ListOfTasks>;
|
||||
}>;
|
||||
|
||||
export default function App() {
|
||||
const [listId, setListId] = useState<CoID<TodoList>>();
|
||||
|
||||
const { localNode, logOut } = useJazz();
|
||||
|
||||
useEffect(() => {
|
||||
const listener = async () => {
|
||||
const acceptedInvitation =
|
||||
await consumeInviteLinkFromWindowLocation(localNode);
|
||||
|
||||
if (acceptedInvitation) {
|
||||
setListId(acceptedInvitation.valueID as CoID<TodoList>);
|
||||
window.location.hash = acceptedInvitation.valueID;
|
||||
return;
|
||||
}
|
||||
|
||||
setListId(window.location.hash.slice(1) as CoID<TodoList>);
|
||||
};
|
||||
window.addEventListener("hashchange", listener);
|
||||
listener();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", listener);
|
||||
};
|
||||
}, [localNode]);
|
||||
|
||||
const createList = useCallback(
|
||||
(title: string) => {
|
||||
if (!title) return;
|
||||
const listGroup = localNode.createGroup();
|
||||
const list = listGroup.createMap<TodoList>();
|
||||
const tasks = listGroup.createList<ListOfTasks>();
|
||||
|
||||
list.edit((list) => {
|
||||
list.set("title", title);
|
||||
list.set("tasks", tasks.id);
|
||||
});
|
||||
|
||||
window.location.hash = list.id;
|
||||
},
|
||||
[localNode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
{listId ? (
|
||||
<TodoListComponent listId={listId} />
|
||||
) : (
|
||||
<SubmittableInput
|
||||
onSubmit={createList}
|
||||
label="Create New List"
|
||||
placeholder="New list title"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.hash = "";
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TodoListComponent({ listId }: { listId: CoID<TodoList> }) {
|
||||
const list = useTelepathicState(listId);
|
||||
const tasks = useTelepathicState(list?.get("tasks"));
|
||||
|
||||
const createTask = (text: string) => {
|
||||
if (!tasks || !text) return;
|
||||
const task = tasks.coValue.getGroup().createMap<Task>();
|
||||
|
||||
task.edit((task) => {
|
||||
task.set("text", text);
|
||||
task.set("done", false);
|
||||
});
|
||||
|
||||
tasks.edit((tasks) => {
|
||||
tasks.push(task.id);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{list?.get("title") ? (
|
||||
<>
|
||||
{list.get("title")}{" "}
|
||||
<span className="text-sm">({list.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</h1>
|
||||
{list && <InviteButton list={list} />}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Done</TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks &&
|
||||
tasks
|
||||
.asArray()
|
||||
.map((taskId) => (
|
||||
<TaskRow key={taskId} taskId={taskId} />
|
||||
))}
|
||||
<TableRow key="new">
|
||||
<TableCell>
|
||||
<Checkbox className="mt-1" disabled />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubmittableInput
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={!list}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
const task = useTelepathicState(taskId);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
className="mt-1"
|
||||
checked={task?.get("done")}
|
||||
onCheckedChange={(checked) => {
|
||||
task?.edit((task) => {
|
||||
task.set("done", !!checked);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
<span className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text") || (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</span>
|
||||
<NameBadge accountID={task?.getLastEditor("text")} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function NameBadge({ accountID }: { accountID?: AccountID }) {
|
||||
const profile = useProfile(accountID);
|
||||
|
||||
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
|
||||
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
|
||||
|
||||
return (
|
||||
profile?.get("name") ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={{
|
||||
color: theme == "light" ? darkColor : brightColor,
|
||||
background: theme == "light" ? brightColor : darkColor,
|
||||
}}
|
||||
>
|
||||
{profile.get("name")}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InviteButton({ list }: { list: TodoList }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
list.coValue.getGroup().myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (list && !inviteLink) {
|
||||
inviteLink = createInviteLink(list, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
const qr = await QRCode.toDataURL(inviteLink, {
|
||||
errorCorrectionLevel: "L",
|
||||
});
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
title: "Copied invite link to clipboard!",
|
||||
description: (
|
||||
<img src={qr} className="w-20 h-20" />
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/basicComponents/ui/input";
|
||||
import { Button } from "@/basicComponents/ui/button";
|
||||
|
||||
export function SubmittableInput({
|
||||
onSubmit,
|
||||
10
examples/todo/src/basicComponents/TitleAndLogo.tsx
Normal file
10
examples/todo/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 />
|
||||
</>
|
||||
}
|
||||
17
examples/todo/src/basicComponents/index.ts
Normal file
17
examples/todo/src/basicComponents/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { Button } from "./ui/button";
|
||||
export { Checkbox } from "./ui/checkbox";
|
||||
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";
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
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",
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
@@ -3,7 +3,7 @@ import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
} from "@/basicComponents/ui/toast"
|
||||
import { useToast } from "@/basicComponents/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
} from "@/basicComponents/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
@@ -1,9 +1,10 @@
|
||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||
import { useState } from "react";
|
||||
import { Input } from "./ui/input";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export const PrettyAuthComponent: LocalAuthComponent = ({
|
||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||
|
||||
import { Input, Button } from "../basicComponents";
|
||||
|
||||
export const PrettyAuthUI: LocalAuthComponent = ({
|
||||
loading,
|
||||
logIn,
|
||||
signUp,
|
||||
46
examples/todo/src/components/InviteButton.tsx
Normal file
46
examples/todo/src/components/InviteButton.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { TodoProject } from "../1_types";
|
||||
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import { useToast, Button } from "../basicComponents";
|
||||
|
||||
export function InviteButton({ list }: { list?: TodoProject }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
list?.group.myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (list && !inviteLink) {
|
||||
inviteLink = createInviteLink(list, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
const qr = await QRCode.toDataURL(inviteLink, {
|
||||
errorCorrectionLevel: "L",
|
||||
});
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
title: "Copied invite link to clipboard!",
|
||||
description: (
|
||||
<img src={qr} className="w-20 h-20" />
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
46
examples/todo/src/components/NameBadge.tsx
Normal file
46
examples/todo/src/components/NameBadge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AccountID } from "cojson";
|
||||
import { useProfile } from "jazz-react";
|
||||
|
||||
import { Skeleton } from "@/basicComponents";
|
||||
import uniqolor from "uniqolor";
|
||||
|
||||
/** Walkthrough: Getting user profiles in `<NameBadge/>`
|
||||
*
|
||||
* `<NameBadge/>` uses `useProfile(accountID)`, which is a shorthand for
|
||||
* useTelepathicState on an account's profile.
|
||||
*
|
||||
* Profiles are always a `CoMap<{name: string}>`, but they might have app-specific
|
||||
* additional properties).
|
||||
*
|
||||
* In our case, we just display the profile name (which is set by the LocalAuth
|
||||
* provider when we first create an account).
|
||||
*/
|
||||
|
||||
export function NameBadge({ accountID }: { accountID?: AccountID }) {
|
||||
const profile = useProfile(accountID);
|
||||
|
||||
return accountID && profile?.get("name") ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={randomUserColor(accountID)}
|
||||
>
|
||||
{profile.get("name")}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
|
||||
);
|
||||
}
|
||||
|
||||
function randomUserColor(accountID: AccountID) {
|
||||
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
|
||||
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
|
||||
|
||||
return {
|
||||
color: theme == "light" ? darkColor : brightColor,
|
||||
background: theme == "light" ? brightColor : darkColor,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
@@ -14,63 +9,63 @@
|
||||
|
||||
--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;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { WithJazz } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
import { PrettyAuthComponent } from "./components/prettyAuth.tsx";
|
||||
import { ThemeProvider } from "./components/themeProvider.tsx";
|
||||
import { Toaster } from "./components/ui/toaster.tsx";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
// <React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<div className="flex items-center gap-2 justify-center mt-5">
|
||||
<img src="jazz-logo.png" className="h-5" /> Jazz Todo List
|
||||
Example
|
||||
</div>
|
||||
<WithJazz
|
||||
auth={LocalAuth({
|
||||
appName: "Jazz Todo List Example",
|
||||
Component: PrettyAuthComponent,
|
||||
})}
|
||||
syncAddress={
|
||||
new URLSearchParams(window.location.search).get("sync") ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
<App />
|
||||
<Toaster />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
// </React.StrictMode>
|
||||
);
|
||||
37
examples/todo/src/router.ts
Normal file
37
examples/todo/src/router.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CoID, LocalNode, ContentType } from "cojson";
|
||||
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
|
||||
|
||||
export function useSimpleHashRouterThatAcceptsInvites<C extends ContentType>(
|
||||
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;
|
||||
}
|
||||
433
generateDocs.ts
Normal file
433
generateDocs.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { Application, JSONOutput } from "typedoc";
|
||||
|
||||
const manuallyIgnore = new Set(["CojsonInternalTypes"]);
|
||||
|
||||
async function main() {
|
||||
// Application.bootstrap also exists, which will not load plugins
|
||||
// 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",
|
||||
"jazz-browser": "index.ts",
|
||||
}).map(async ([packageName, entryPoint]) => {
|
||||
const app = await Application.bootstrapWithPlugins({
|
||||
entryPoints: [`packages/${packageName}/src/${entryPoint}`],
|
||||
tsconfig: `packages/${packageName}/tsconfig.json`,
|
||||
sort: ["required-first"],
|
||||
});
|
||||
|
||||
const project = await app.convert();
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Failed to convert project" + packageName);
|
||||
}
|
||||
// Alternatively generate JSON output
|
||||
await app.generateJson(project, `docsTmp/${packageName}.json`);
|
||||
|
||||
const docs = JSON.parse(
|
||||
await readFile(`docsTmp/${packageName}.json`, "utf8")
|
||||
) as JSONOutput.ProjectReflection;
|
||||
|
||||
return (
|
||||
`# ${packageName}\n\n` +
|
||||
docs
|
||||
.groups!.map((group) => {
|
||||
return group.children
|
||||
?.map((childId) => {
|
||||
const child = docs.children!.find(
|
||||
(child) => child.id === childId
|
||||
)!;
|
||||
|
||||
if (manuallyIgnore.has(child.name)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
`## \`${renderChildName(child)}\` (${group.title
|
||||
.toLowerCase()
|
||||
.replace("ces", "ce")
|
||||
.replace(/es$/, "")
|
||||
.replace(
|
||||
"ns",
|
||||
"n"
|
||||
)} in \`${packageName}\`)\n\n` +
|
||||
renderChildType(child) +
|
||||
renderComment(child.comment) +
|
||||
(child.kind === 128 || child.kind === 256
|
||||
? child.groups
|
||||
?.map((group) =>
|
||||
renderChildGroup(child, group)
|
||||
)
|
||||
.join("\n\n")
|
||||
: "TODO: doc generator not implemented yet")
|
||||
);
|
||||
})
|
||||
.join("\n\n----\n\n");
|
||||
})
|
||||
.join("\n\n----\n\n")
|
||||
);
|
||||
|
||||
function renderComment(comment?: JSONOutput.Comment): string {
|
||||
if (comment) {
|
||||
return (
|
||||
comment.summary
|
||||
.map((token) =>
|
||||
token.kind === "text" || token.kind === "code"
|
||||
? token.text
|
||||
: ""
|
||||
)
|
||||
.join("") +
|
||||
"\n\n" +
|
||||
(comment.blockTags || [])
|
||||
.map((blockTag) =>
|
||||
blockTag.tag === "@example"
|
||||
? "##### Example:\n\n" +
|
||||
blockTag.content
|
||||
.map((token) =>
|
||||
token.kind === "text" ||
|
||||
token.kind === "code"
|
||||
? token.text
|
||||
: ""
|
||||
)
|
||||
.join("")
|
||||
: ""
|
||||
)
|
||||
.join("\n\n") +
|
||||
"\n\n"
|
||||
);
|
||||
} else {
|
||||
return "TODO: document\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
function renderChildName(child: JSONOutput.DeclarationReflection) {
|
||||
if (child.signatures) {
|
||||
if (
|
||||
child.signatures[0].type?.type === "reference" &&
|
||||
child.signatures[0].type.qualifiedName ===
|
||||
"React.JSX.Element"
|
||||
) {
|
||||
return `<${child.name}/>`;
|
||||
} else {
|
||||
return (
|
||||
child.name +
|
||||
`(${(child.signatures[0].parameters || [])
|
||||
.map(renderParamSimple)
|
||||
.join(", ")})`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return child.name;
|
||||
}
|
||||
}
|
||||
|
||||
function renderChildType(
|
||||
child: JSONOutput.DeclarationReflection
|
||||
): string {
|
||||
const isClass = child.kind === 128;
|
||||
const isTypeDef = child.kind === 2097152;
|
||||
const isInterface = child.kind === 256;
|
||||
const isFunction = !!child.signatures;
|
||||
return (
|
||||
"```typescript\n" +
|
||||
`export ${
|
||||
isClass
|
||||
? "class"
|
||||
: isTypeDef
|
||||
? "type"
|
||||
: isFunction
|
||||
? "function"
|
||||
: isInterface
|
||||
? "interface"
|
||||
: ""
|
||||
} ${child.name}` +
|
||||
(child.typeParameters
|
||||
? "<" +
|
||||
child.typeParameters.map(renderTypeParam).join(", ") +
|
||||
">"
|
||||
: "") +
|
||||
(child.extendedTypes
|
||||
? " extends " +
|
||||
child.extendedTypes.map(renderType).join(", ")
|
||||
: "") +
|
||||
(child.implementedTypes
|
||||
? " implements " +
|
||||
child.implementedTypes.map(renderType).join(", ")
|
||||
: "") +
|
||||
(isClass || isInterface
|
||||
? " {...}"
|
||||
: isTypeDef
|
||||
? ` = ${renderType(child.type)}`
|
||||
: child.signatures
|
||||
? `(${(child.signatures[0].parameters || [])
|
||||
.map(renderParam)
|
||||
.join(", ")}): ${renderType(
|
||||
child.signatures[0].type
|
||||
)}`
|
||||
: "") +
|
||||
"\n```\n"
|
||||
);
|
||||
}
|
||||
|
||||
function renderChildGroup(
|
||||
child: JSONOutput.DeclarationReflection,
|
||||
group: JSONOutput.ReflectionGroup
|
||||
): string {
|
||||
return (
|
||||
`### ${group.title}\n\n` +
|
||||
group.children
|
||||
?.map((memberId) => {
|
||||
const member = child.children!.find(
|
||||
(child) => child.id === memberId
|
||||
)!;
|
||||
|
||||
if (member.kind === 2048 || member.kind === 512) {
|
||||
return documentConstructorOrMethod(member, child);
|
||||
} else if (
|
||||
member.kind === 1024 ||
|
||||
member.kind === 262144
|
||||
) {
|
||||
return documentProperty(member, child);
|
||||
} else {
|
||||
return "Unknown member kind " + member.kind;
|
||||
}
|
||||
})
|
||||
.join("\n\n")
|
||||
);
|
||||
}
|
||||
|
||||
function renderType(t?: JSONOutput.SomeType): string {
|
||||
if (!t) return "";
|
||||
if (t.type === "reference") {
|
||||
return (
|
||||
t.name +
|
||||
(t.typeArguments
|
||||
? "<" + t.typeArguments.map(renderType).join(", ") + ">"
|
||||
: "")
|
||||
);
|
||||
} else if (t.type === "intrinsic") {
|
||||
return t.name;
|
||||
} else if (t.type === "literal") {
|
||||
return JSON.stringify(t.value);
|
||||
} else if (t.type === "union") {
|
||||
return [...new Set(t.types.map(renderType))].join(" | ");
|
||||
} else if (t.type === "intersection") {
|
||||
return [...new Set(t.types.map(renderType))].join(" & ");
|
||||
} else if (t.type === "indexedAccess") {
|
||||
return (
|
||||
renderType(t.objectType) +
|
||||
"[" +
|
||||
renderType(t.indexType) +
|
||||
"]"
|
||||
);
|
||||
} else if (t.type === "reflection") {
|
||||
if (t.declaration.indexSignature) {
|
||||
return (
|
||||
"{ [" +
|
||||
t.declaration.indexSignature?.parameters?.[0].name +
|
||||
": " +
|
||||
renderType(
|
||||
t.declaration.indexSignature?.parameters?.[0].type
|
||||
) +
|
||||
"]: " +
|
||||
renderType(t.declaration.indexSignature?.type) +
|
||||
" }"
|
||||
);
|
||||
} else if (t.declaration.children) {
|
||||
return `{${t.declaration.children
|
||||
.map(
|
||||
(child) =>
|
||||
`${child.name}${
|
||||
child.flags.isOptional ? "?" : ""
|
||||
}: ${renderType(child.type)}`
|
||||
)
|
||||
.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
|
||||
)}`;
|
||||
}
|
||||
} else {
|
||||
return "COMPLEX_TYPE_REFLECTION";
|
||||
}
|
||||
} else if (t.type === "array") {
|
||||
return renderType(t.elementType) + "[]";
|
||||
} else if (t.type === "templateLiteral") {
|
||||
const matchingNamedType = docs.children?.find(
|
||||
(child) =>
|
||||
child.variant === "declaration" &&
|
||||
child.type?.type === "templateLiteral" &&
|
||||
child.type.head === t.head &&
|
||||
child.type.tail.every(
|
||||
(piece, i) => piece[1] === t.tail[i][1]
|
||||
)
|
||||
);
|
||||
|
||||
if (matchingNamedType) {
|
||||
return matchingNamedType.name;
|
||||
} else {
|
||||
if (
|
||||
t.head === "sealerSecret_z" &&
|
||||
t.tail[0][1] === "/signerSecret_z"
|
||||
) {
|
||||
return "AgentSecret";
|
||||
} else if (
|
||||
t.head === "sealer_z" &&
|
||||
t.tail[0][1] === "/signer_z"
|
||||
) {
|
||||
if (t.tail[1] && t.tail[1][1] === "_session_z") {
|
||||
return "SessionID";
|
||||
} else {
|
||||
return "AgentID";
|
||||
}
|
||||
} else {
|
||||
return "TEMPLATE_LITERAL";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return "COMPLEX_TYPE_" + t.type;
|
||||
}
|
||||
}
|
||||
|
||||
// function renderTemplateLiteral(tempLit: JSONOutput.TemplateLiteralType) {
|
||||
// return tempLit.head + tempLit.tail.map((piece) => piece[0] + piece[1]).join("");
|
||||
// }
|
||||
|
||||
// function resolveTemplateLiteralPieceType(t: SomeType): string {
|
||||
// if (t.type === "string") {
|
||||
// return "${string}"
|
||||
// }
|
||||
// if (t.type === "reference") {
|
||||
// const referencedType = docs.children?.find(
|
||||
// (child) => child.name === t.name
|
||||
// );
|
||||
|
||||
// }
|
||||
// }
|
||||
|
||||
function renderTypeParam(
|
||||
t?: JSONOutput.TypeParameterReflection
|
||||
): string {
|
||||
if (!t) return "";
|
||||
return t.name + (t.type ? " extends " + renderType(t.type) : "");
|
||||
}
|
||||
|
||||
function renderParam(param: JSONOutput.ParameterReflection) {
|
||||
return param.name === "__namedParameters"
|
||||
? renderType(param.type)
|
||||
: `${param.name}: ${renderType(param.type)}`;
|
||||
}
|
||||
|
||||
function renderParamSimple(param: JSONOutput.ParameterReflection) {
|
||||
return param.name === "__namedParameters" &&
|
||||
param.type?.type === "reflection"
|
||||
? `{${param.type?.declaration.children
|
||||
?.map(
|
||||
(child) =>
|
||||
child.name + (child.flags.isOptional ? "?" : "")
|
||||
)
|
||||
.join(", ")}}${param.defaultValue ? "?" : ""}`
|
||||
: param.name + (param.defaultValue ? "?" : "");
|
||||
}
|
||||
|
||||
function documentConstructorOrMethod(
|
||||
member: JSONOutput.DeclarationReflection,
|
||||
child: JSONOutput.DeclarationReflection
|
||||
) {
|
||||
const stem =
|
||||
member.name === "constructor"
|
||||
? "new " + child.name
|
||||
: (member.flags.isStatic
|
||||
? child.name
|
||||
: child.name[0].toLowerCase() + child.name.slice(1)) +
|
||||
"." +
|
||||
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 (
|
||||
"```typescript\n" +
|
||||
`${stem}${
|
||||
signature.typeParameter
|
||||
? `<${signature.typeParameter
|
||||
.map(renderTypeParam)
|
||||
.join(", ")}>`
|
||||
: ""
|
||||
}(${
|
||||
(
|
||||
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)
|
||||
);
|
||||
}) +
|
||||
"</details>\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);
|
||||
return (
|
||||
`<details>\n<summary><code>${stem}.${member.name}</code> ${
|
||||
member.inheritedFrom
|
||||
? "(from <code>" +
|
||||
member.inheritedFrom.name.split(".")[0] +
|
||||
"</code>) "
|
||||
: ""
|
||||
} ${
|
||||
member.comment ? "" : "(undocumented)"
|
||||
}</summary>\n\n` +
|
||||
"```typescript\n" +
|
||||
`${member.getSignature ? "get " : ""}${stem}.${member.name}${
|
||||
member.getSignature ? "()" : ""
|
||||
}: ${renderType(member.type || member.getSignature?.type)}\n` +
|
||||
"```\n" +
|
||||
renderComment(member.comment) +
|
||||
"</details>\n\n"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await writeFile(
|
||||
"./DOCS.md",
|
||||
(await Promise.all(packageDocs)).join("\n\n\n")
|
||||
);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
10
package.json
10
package.json
@@ -7,6 +7,14 @@
|
||||
],
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"lerna": "^7.1.5"
|
||||
"lerna": "^7.1.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"typedoc": "^0.25.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build-all": "lerna run build",
|
||||
"updated": "lerna updated --include-merged-tags",
|
||||
"publish-all": "yarn run gen-docs && lerna publish --include-merged-tags",
|
||||
"gen-docs": "ts-node generateDocs.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.10",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/ws": "^8.5.5",
|
||||
@@ -16,8 +16,8 @@
|
||||
"typescript": "5.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.5",
|
||||
"cojson-storage-sqlite": "^0.1.3",
|
||||
"cojson": "^0.1.9",
|
||||
"cojson-storage-sqlite": "^0.1.7",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.7",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.5.2",
|
||||
"cojson": "^0.1.5",
|
||||
"cojson": "^0.1.9",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.9",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { newRandomSessionID } from "./coValue.js";
|
||||
import { newRandomSessionID } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
|
||||
@@ -34,7 +34,7 @@ test("A node with an account can create groups and and objects within them", asy
|
||||
|
||||
expect(map.get("foo")).toEqual("bar");
|
||||
|
||||
expect(map.getLastEditor("foo")).toEqual(accountID);
|
||||
expect(map.whoEdited("foo")).toEqual(accountID);
|
||||
});
|
||||
|
||||
test("Can create account with one node, and then load it on another", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CoValueHeader } from "./coValue.js";
|
||||
import { CoID } from "./contentType.js";
|
||||
import { CoValueHeader } from "./coValueCore.js";
|
||||
import { CoID } from "./coValue.js";
|
||||
import {
|
||||
AgentSecret,
|
||||
SealerID,
|
||||
@@ -33,11 +33,11 @@ export function accountHeaderForInitialAgentSecret(
|
||||
|
||||
export class Account extends Group {
|
||||
get id(): AccountID {
|
||||
return this.groupMap.id as AccountID;
|
||||
return this.underlyingMap.id as AccountID;
|
||||
}
|
||||
|
||||
getCurrentAgentID(): AgentID {
|
||||
const agents = this.groupMap
|
||||
const agents = this.underlyingMap
|
||||
.keys()
|
||||
.filter((k): k is AgentID => k.startsWith("sealer_"));
|
||||
|
||||
@@ -52,7 +52,7 @@ export class Account extends Group {
|
||||
}
|
||||
|
||||
export interface GeneralizedControlledAccount {
|
||||
id: AccountIDOrAgentID;
|
||||
id: AccountID | AgentID;
|
||||
agentSecret: AgentSecret;
|
||||
|
||||
currentAgentID: () => AgentID;
|
||||
@@ -62,6 +62,7 @@ export interface GeneralizedControlledAccount {
|
||||
currentSealerSecret: () => SealerSecret;
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
export class ControlledAccount
|
||||
extends Account
|
||||
implements GeneralizedControlledAccount
|
||||
@@ -99,6 +100,7 @@ export class ControlledAccount
|
||||
}
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
export class AnonymousControlledAccount
|
||||
implements GeneralizedControlledAccount
|
||||
{
|
||||
@@ -138,11 +140,7 @@ export type AccountMeta = { type: "account" };
|
||||
export type AccountMap = CoMap<AccountContent, AccountMeta>;
|
||||
export type AccountID = CoID<AccountMap>;
|
||||
|
||||
export type AccountIDOrAgentID = AgentID | AccountID;
|
||||
export type AccountOrAgentID = AgentID | Account;
|
||||
export type AccountOrAgentSecret = AgentSecret | Account;
|
||||
|
||||
export function isAccountID(id: AccountIDOrAgentID): id is AccountID {
|
||||
export function isAccountID(id: AccountID | AgentID): id is AccountID {
|
||||
return id.startsWith("co_");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,181 +1,284 @@
|
||||
import { Transaction } from "./coValue.js";
|
||||
import { accountOrAgentIDfromSessionID } from "./coValueCore.js";
|
||||
import { createdNowUnique } from "./crypto.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { AccountID } from "./index.js";
|
||||
import { Role } from "./permissions.js";
|
||||
|
||||
test("Can create coValue with new agent credentials and add transaction to it", () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
test("Empty CoMap works", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "costream",
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
[transaction]
|
||||
);
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(account.currentSignerSecret(), expectedNewHash)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(content.type).toEqual("comap");
|
||||
expect([...content.keys()]).toEqual([]);
|
||||
expect(content.toJSON()).toEqual({});
|
||||
});
|
||||
|
||||
test("transactions with wrong signature are rejected", () => {
|
||||
const wrongAgent = newRandomAgentSecret();
|
||||
const [agentSecret, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(agentSecret, sessionID);
|
||||
test("Can insert and delete CoMap entries in edit()", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "costream",
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
[transaction]
|
||||
);
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(getAgentSignerSecret(wrongAgent), expectedNewHash)
|
||||
)
|
||||
).toBe(false);
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set("hello", "world", "trusting");
|
||||
expect(editable.get("hello")).toEqual("world");
|
||||
editable.set("foo", "bar", "trusting");
|
||||
expect(editable.get("foo")).toEqual("bar");
|
||||
expect([...editable.keys()]).toEqual(["hello", "foo"]);
|
||||
editable.delete("foo", "trusting");
|
||||
expect(editable.get("foo")).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
test("Can get CoMap entry values at different points in time", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "costream",
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
[
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "wrong",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
);
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(account.currentSignerSecret(), expectedNewHash)
|
||||
)
|
||||
).toBe(false);
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
const beforeA = Date.now();
|
||||
while (Date.now() < beforeA + 10) {}
|
||||
editable.set("hello", "A", "trusting");
|
||||
const beforeB = Date.now();
|
||||
while (Date.now() < beforeB + 10) {}
|
||||
editable.set("hello", "B", "trusting");
|
||||
const beforeC = Date.now();
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
test("New transactions in a group correctly update owned values, including subscriptions", async () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
test("Can get all historic values of key in CoMap", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
const timeBeforeEdit = Date.now();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
let map = group.createMap();
|
||||
|
||||
let mapAfterEdit = map.edit((map) => {
|
||||
map.set("hello", "world");
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const listener = jest.fn().mockImplementation();
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
map.subscribe(listener);
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(listener.mock.calls[0][0].get("hello")).toBe("world");
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
const resignationThatWeJustLearnedAbout = {
|
||||
privacy: "trusting",
|
||||
madeAt: timeBeforeEdit,
|
||||
changes: [
|
||||
content.edit((editable) => {
|
||||
editable.set("hello", "A", "trusting");
|
||||
const txA = editable.getLastTxID("hello");
|
||||
editable.set("hello", "B", "trusting");
|
||||
const txB = editable.getLastTxID("hello");
|
||||
editable.delete("hello", "trusting");
|
||||
const txDel = editable.getLastTxID("hello");
|
||||
editable.set("hello", "C", "trusting");
|
||||
const txC = editable.getLastTxID("hello");
|
||||
expect(editable.getHistory("hello")).toEqual([
|
||||
{
|
||||
op: "set",
|
||||
key: account.id,
|
||||
value: "revoked"
|
||||
} satisfies MapOpPayload<typeof account.id, Role>
|
||||
]
|
||||
} satisfies Transaction;
|
||||
|
||||
const { expectedNewHash } = group.groupMap.coValue.expectedNewHashAfter(sessionID, [
|
||||
resignationThatWeJustLearnedAbout,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
node.account.currentSignerSecret(),
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
expect(map.coValue.getValidSortedTransactions().length).toBe(1);
|
||||
|
||||
const manuallyAdddedTxSuccess = group.groupMap.coValue.tryAddTransactions(node.ownSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
|
||||
|
||||
expect(manuallyAdddedTxSuccess).toBe(true);
|
||||
|
||||
expect(listener.mock.calls.length).toBe(2);
|
||||
expect(listener.mock.calls[1][0].get("hello")).toBe(undefined);
|
||||
|
||||
expect(map.coValue.getValidSortedTransactions().length).toBe(0);
|
||||
txID: txA,
|
||||
value: "A",
|
||||
at: txA && coValue.getTx(txA)?.madeAt,
|
||||
},
|
||||
{
|
||||
txID: txB,
|
||||
value: "B",
|
||||
at: txB && coValue.getTx(txB)?.madeAt,
|
||||
},
|
||||
{
|
||||
txID: txDel,
|
||||
value: undefined,
|
||||
at: txDel && coValue.getTx(txDel)?.madeAt,
|
||||
},
|
||||
{
|
||||
txID: txC,
|
||||
value: "C",
|
||||
at: txC && coValue.getTx(txC)?.madeAt,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can get last tx ID for a key in CoMap", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
expect(editable.getLastTxID("hello")).toEqual(undefined);
|
||||
editable.set("hello", "A", "trusting");
|
||||
const sessionID = editable.getLastTxID("hello")?.sessionID;
|
||||
expect(sessionID && accountOrAgentIDfromSessionID(sessionID)).toEqual(
|
||||
node.account.id
|
||||
);
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(0);
|
||||
editable.set("hello", "B", "trusting");
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(1);
|
||||
editable.set("hello", "C", "trusting");
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test("Empty CoList works", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
expect(content.toJSON()).toEqual([]);
|
||||
});
|
||||
|
||||
test("Can append, prepend and delete items to CoList", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.append(0, "hello", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello"]);
|
||||
editable.append(0, "world", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "world"]);
|
||||
editable.prepend(1, "beautiful", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "beautiful", "world"]);
|
||||
editable.prepend(3, "hooray", "trusting");
|
||||
expect(editable.toJSON()).toEqual([
|
||||
"hello",
|
||||
"beautiful",
|
||||
"world",
|
||||
"hooray",
|
||||
]);
|
||||
editable.delete(2, "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "beautiful", "hooray"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("Push is equivalent to append after last item", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.append(0, "hello", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello"]);
|
||||
editable.push("world", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "world"]);
|
||||
editable.push("hooray", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "world", "hooray"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can push into empty list", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.push("hello", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello"]);
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,589 +1,62 @@
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
import { ContentType } from "./contentType.js";
|
||||
import { Static } from "./contentTypes/static.js";
|
||||
import { CoStream } from "./contentTypes/coStream.js";
|
||||
import { CoMap } from "./contentTypes/coMap.js";
|
||||
import {
|
||||
Encrypted,
|
||||
Hash,
|
||||
KeySecret,
|
||||
Signature,
|
||||
StreamingHash,
|
||||
unseal,
|
||||
shortHash,
|
||||
sign,
|
||||
verify,
|
||||
encryptForTransaction,
|
||||
decryptForTransaction,
|
||||
KeyID,
|
||||
decryptKeySecret,
|
||||
getAgentSignerID,
|
||||
getAgentSealerID,
|
||||
} from "./crypto.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import {
|
||||
PermissionsDef as RulesetDef,
|
||||
determineValidTransactions,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import { CoList } from "./contentTypes/coList.js";
|
||||
import {
|
||||
AccountID,
|
||||
AccountIDOrAgentID,
|
||||
GeneralizedControlledAccount,
|
||||
} from "./account.js";
|
||||
import { RawCoID } from "./ids.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { CoStream } from "./coValues/coStream.js";
|
||||
import { Static } from "./coValues/static.js";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import { CoValueCore, Group } from "./index.js";
|
||||
|
||||
export type CoValueHeader = {
|
||||
type: ContentType["type"];
|
||||
ruleset: RulesetDef;
|
||||
export type CoID<T extends CoValueImpl> = RawCoID & {
|
||||
readonly __type: T;
|
||||
};
|
||||
|
||||
export interface ReadableCoValue extends CoValue {
|
||||
/** 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.
|
||||
*
|
||||
* Returns an unsubscribe function.
|
||||
*
|
||||
* Used internally by `useTelepathicData()` for reactive updates on changes to a `CoValue`. */
|
||||
subscribe(listener: (coValue: CoValueImpl) => void): () => void;
|
||||
/** Lets you apply edits to a `CoValue`, inside the changer callback, which receives a `WriteableCoValue`.
|
||||
*
|
||||
* A `WritableCoValue` has all the same methods as a `CoValue`, but all edits made to it (with its additional mutator methods)
|
||||
* are reflected in it immediately - so it behaves mutably, whereas a `CoValue` is always immutable
|
||||
* (you need to use `subscribe` to receive new versions of it). */
|
||||
edit?:
|
||||
| ((changer: (editable: WriteableCoValue) => void) => CoValueImpl)
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export interface CoValue {
|
||||
/** The `CoValue`'s (precisely typed) `CoID` */
|
||||
id: CoID<CoValueImpl>;
|
||||
core: CoValueCore;
|
||||
/** Specifies which kind of `CoValue` this is */
|
||||
type: CoValueImpl["type"];
|
||||
/** The `CoValue`'s (precisely typed) static metadata */
|
||||
meta: JsonObject | null;
|
||||
createdAt: `2${string}` | null;
|
||||
uniqueness: `z${string}` | null;
|
||||
};
|
||||
|
||||
export function idforHeader(header: CoValueHeader): RawCoID {
|
||||
const hash = shortHash(header);
|
||||
return `co_z${hash.slice("shortHash_z".length)}`;
|
||||
/** The `Group` this `CoValue` belongs to (determining permissions) */
|
||||
group: Group;
|
||||
/** Returns an immutable JSON presentation of this `CoValue` */
|
||||
toJSON(): JsonValue;
|
||||
}
|
||||
|
||||
export function accountOrAgentIDfromSessionID(
|
||||
sessionID: SessionID
|
||||
): AccountIDOrAgentID {
|
||||
return sessionID.split("_session")[0] as AccountIDOrAgentID;
|
||||
}
|
||||
|
||||
export function newRandomSessionID(accountID: AccountIDOrAgentID): SessionID {
|
||||
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
|
||||
}
|
||||
|
||||
type SessionLog = {
|
||||
transactions: Transaction[];
|
||||
lastHash?: Hash;
|
||||
streamingHash: StreamingHash;
|
||||
lastSignature: Signature;
|
||||
};
|
||||
|
||||
export type PrivateTransaction = {
|
||||
privacy: "private";
|
||||
madeAt: number;
|
||||
keyUsed: KeyID;
|
||||
encryptedChanges: Encrypted<
|
||||
JsonValue[],
|
||||
{ in: RawCoID; tx: TransactionID }
|
||||
>;
|
||||
};
|
||||
|
||||
export type TrustingTransaction = {
|
||||
privacy: "trusting";
|
||||
madeAt: number;
|
||||
changes: JsonValue[];
|
||||
};
|
||||
|
||||
export type Transaction = PrivateTransaction | TrustingTransaction;
|
||||
|
||||
export type DecryptedTransaction = {
|
||||
txID: TransactionID;
|
||||
changes: JsonValue[];
|
||||
madeAt: number;
|
||||
};
|
||||
|
||||
const readKeyCache = new WeakMap<CoValue, { [id: KeyID]: KeySecret }>();
|
||||
|
||||
export class CoValue {
|
||||
id: RawCoID;
|
||||
node: LocalNode;
|
||||
header: CoValueHeader;
|
||||
_sessions: { [key: SessionID]: SessionLog };
|
||||
_cachedContent?: ContentType;
|
||||
listeners: Set<(content?: ContentType) => void> = new Set();
|
||||
|
||||
constructor(
|
||||
header: CoValueHeader,
|
||||
node: LocalNode,
|
||||
internalInitSessions: { [key: SessionID]: SessionLog } = {}
|
||||
) {
|
||||
this.id = idforHeader(header);
|
||||
this.header = header;
|
||||
this._sessions = internalInitSessions;
|
||||
this.node = node;
|
||||
|
||||
if (header.ruleset.type == "ownedByGroup") {
|
||||
this.node
|
||||
.expectCoValueLoaded(header.ruleset.group)
|
||||
.subscribe((_groupUpdate) => {
|
||||
this._cachedContent = undefined;
|
||||
const newContent = this.getCurrentContent();
|
||||
for (const listener of this.listeners) {
|
||||
listener(newContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get sessions(): Readonly<{ [key: SessionID]: SessionLog }> {
|
||||
return this._sessions;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
ownSessionID: SessionID
|
||||
): CoValue {
|
||||
const newNode = this.node.testWithDifferentAccount(
|
||||
account,
|
||||
ownSessionID
|
||||
);
|
||||
|
||||
return newNode.expectCoValueLoaded(this.id);
|
||||
}
|
||||
|
||||
knownState(): CoValueKnownState {
|
||||
return {
|
||||
id: this.id,
|
||||
header: true,
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(this.sessions).map(([k, v]) => [
|
||||
k,
|
||||
v.transactions.length,
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
get meta(): JsonValue {
|
||||
return this.header?.meta ?? null;
|
||||
}
|
||||
|
||||
nextTransactionID(): TransactionID {
|
||||
const sessionID = this.node.ownSessionID;
|
||||
return {
|
||||
sessionID,
|
||||
txIndex: this.sessions[sessionID]?.transactions.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
tryAddTransactions(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
givenExpectedNewHash: Hash | undefined,
|
||||
newSignature: Signature
|
||||
): boolean {
|
||||
const signerID = getAgentSignerID(
|
||||
this.node.resolveAccountAgent(
|
||||
accountOrAgentIDfromSessionID(sessionID),
|
||||
"Expected to know signer of transaction"
|
||||
)
|
||||
);
|
||||
|
||||
if (!signerID) {
|
||||
console.warn(
|
||||
"Unknown agent",
|
||||
accountOrAgentIDfromSessionID(sessionID)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
|
||||
sessionID,
|
||||
newTransactions
|
||||
);
|
||||
|
||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
||||
console.warn("Invalid hash", {
|
||||
expectedNewHash,
|
||||
givenExpectedNewHash,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!verify(newSignature, expectedNewHash, signerID)) {
|
||||
console.warn(
|
||||
"Invalid signature",
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
signerID
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
||||
|
||||
transactions.push(...newTransactions);
|
||||
|
||||
this._sessions[sessionID] = {
|
||||
transactions,
|
||||
lastHash: expectedNewHash,
|
||||
streamingHash: newStreamingHash,
|
||||
lastSignature: newSignature,
|
||||
};
|
||||
|
||||
this._cachedContent = undefined;
|
||||
|
||||
const content = this.getCurrentContent();
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
listener(content);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
subscribe(listener: (content?: ContentType) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getCurrentContent());
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
expectedNewHashAfter(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[]
|
||||
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
|
||||
const streamingHash =
|
||||
this.sessions[sessionID]?.streamingHash.clone() ??
|
||||
new StreamingHash();
|
||||
for (const transaction of newTransactions) {
|
||||
streamingHash.update(transaction);
|
||||
}
|
||||
|
||||
const newStreamingHash = streamingHash.clone();
|
||||
|
||||
return {
|
||||
expectedNewHash: streamingHash.digest(),
|
||||
newStreamingHash,
|
||||
};
|
||||
}
|
||||
|
||||
makeTransaction(
|
||||
changes: JsonValue[],
|
||||
privacy: "private" | "trusting"
|
||||
): boolean {
|
||||
const madeAt = Date.now();
|
||||
|
||||
let transaction: Transaction;
|
||||
|
||||
if (privacy === "private") {
|
||||
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
|
||||
|
||||
if (!keySecret) {
|
||||
throw new Error(
|
||||
"Can't make transaction without read key secret"
|
||||
);
|
||||
}
|
||||
|
||||
transaction = {
|
||||
privacy: "private",
|
||||
madeAt,
|
||||
keyUsed: keyID,
|
||||
encryptedChanges: encryptForTransaction(changes, keySecret, {
|
||||
in: this.id,
|
||||
tx: this.nextTransactionID(),
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt,
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionID = this.node.ownSessionID;
|
||||
|
||||
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
|
||||
transaction,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
this.node.account.currentSignerSecret(),
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
const success = this.tryAddTransactions(
|
||||
sessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
signature
|
||||
);
|
||||
|
||||
if (success) {
|
||||
void this.node.sync.syncCoValue(this);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
getCurrentContent(): ContentType {
|
||||
if (this._cachedContent) {
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
if (this.header.type === "comap") {
|
||||
this._cachedContent = new CoMap(this);
|
||||
} else if (this.header.type === "colist") {
|
||||
this._cachedContent = new CoList(this);
|
||||
} else if (this.header.type === "costream") {
|
||||
this._cachedContent = new CoStream(this);
|
||||
} else if (this.header.type === "static") {
|
||||
this._cachedContent = new Static(this);
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||
}
|
||||
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
getValidSortedTransactions(): DecryptedTransaction[] {
|
||||
const validTransactions = determineValidTransactions(this);
|
||||
|
||||
const allTransactions: DecryptedTransaction[] = validTransactions
|
||||
.map(({ txID, tx }) => {
|
||||
if (tx.privacy === "trusting") {
|
||||
return {
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: tx.changes,
|
||||
};
|
||||
} else {
|
||||
const readKey = this.getReadKey(tx.keyUsed);
|
||||
|
||||
if (!readKey) {
|
||||
return undefined;
|
||||
} else {
|
||||
const decrytedChanges = decryptForTransaction(
|
||||
tx.encryptedChanges,
|
||||
readKey,
|
||||
{
|
||||
in: this.id,
|
||||
tx: txID,
|
||||
}
|
||||
);
|
||||
|
||||
if (!decrytedChanges) {
|
||||
console.error(
|
||||
"Failed to decrypt transaction despite having key"
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: decrytedChanges,
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x);
|
||||
allTransactions.sort(
|
||||
(a, b) =>
|
||||
a.madeAt - b.madeAt ||
|
||||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
|
||||
a.txID.txIndex - b.txID.txIndex
|
||||
);
|
||||
|
||||
return allTransactions;
|
||||
}
|
||||
|
||||
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
|
||||
if (this.header.ruleset.type === "group") {
|
||||
const content = expectGroupContent(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.get("readKey");
|
||||
|
||||
if (!currentKeyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
const secret = this.getReadKey(currentKeyId);
|
||||
|
||||
return {
|
||||
secret: secret,
|
||||
id: currentKeyId,
|
||||
};
|
||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentReadKey();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only groups or values owned by groups have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (readKeyCache.get(this)?.[keyID]) {
|
||||
return readKeyCache.get(this)?.[keyID];
|
||||
}
|
||||
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}`
|
||||
);
|
||||
|
||||
if (readKeyEntry) {
|
||||
const revealer = accountOrAgentIDfromSessionID(
|
||||
readKeyEntry.txID.sessionID
|
||||
);
|
||||
const revealerAgent = this.node.resolveAccountAgent(
|
||||
revealer,
|
||||
"Expected to know revealer"
|
||||
);
|
||||
|
||||
const secret = unseal(
|
||||
readKeyEntry.value,
|
||||
this.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: readKeyEntry.txID,
|
||||
}
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = secret;
|
||||
|
||||
return secret as KeySecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
|
||||
for (const field of content.keys()) {
|
||||
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
|
||||
const encryptingKeyID = field.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret =
|
||||
this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = content.get(field)!;
|
||||
|
||||
const secret = decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = secret;
|
||||
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
console.error(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getReadKey(keyID);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only groups or values owned by groups have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getGroup(): Group {
|
||||
if (this.header.ruleset.type !== "ownedByGroup") {
|
||||
throw new Error("Only values owned by groups have groups");
|
||||
}
|
||||
|
||||
return new Group(
|
||||
expectGroupContent(
|
||||
this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentContent()
|
||||
),
|
||||
this.node
|
||||
);
|
||||
}
|
||||
|
||||
getTx(txID: TransactionID): Transaction | undefined {
|
||||
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
|
||||
}
|
||||
|
||||
newContentSince(
|
||||
knownState: CoValueKnownState | undefined
|
||||
): NewContentMessage | undefined {
|
||||
const newContent: NewContentMessage = {
|
||||
action: "content",
|
||||
id: this.id,
|
||||
header: knownState?.header ? undefined : this.header,
|
||||
new: Object.fromEntries(
|
||||
Object.entries(this.sessions)
|
||||
.map(([sessionID, log]) => {
|
||||
const newTransactions = log.transactions.slice(
|
||||
knownState?.sessions[sessionID as SessionID] || 0
|
||||
);
|
||||
|
||||
if (
|
||||
newTransactions.length === 0 ||
|
||||
!log.lastHash ||
|
||||
!log.lastSignature
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
sessionID,
|
||||
{
|
||||
after:
|
||||
knownState?.sessions[
|
||||
sessionID as SessionID
|
||||
] || 0,
|
||||
newTransactions,
|
||||
lastSignature: log.lastSignature,
|
||||
},
|
||||
];
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x)
|
||||
),
|
||||
};
|
||||
|
||||
if (!newContent.header && Object.keys(newContent.new).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return newContent;
|
||||
}
|
||||
|
||||
getDependedOnCoValues(): RawCoID[] {
|
||||
return this.header.ruleset.type === "group"
|
||||
? expectGroupContent(this.getCurrentContent())
|
||||
.keys()
|
||||
.filter((k): k is AccountID => k.startsWith("co_"))
|
||||
: this.header.ruleset.type === "ownedByGroup"
|
||||
? [this.header.ruleset.group]
|
||||
: [];
|
||||
}
|
||||
export interface WriteableCoValue extends CoValue {}
|
||||
|
||||
export type CoValueImpl =
|
||||
| CoMap<{ [key: string]: JsonValue }, JsonObject | null>
|
||||
| CoList<JsonValue, JsonObject | null>
|
||||
| CoStream<JsonValue, JsonObject | null>
|
||||
| Static<JsonObject>;
|
||||
|
||||
export function expectMap(
|
||||
content: CoValueImpl
|
||||
): CoMap<{ [key: string]: string }, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<{ [key: string]: string }, JsonObject | null>;
|
||||
}
|
||||
|
||||
181
packages/cojson/src/coValueCore.test.ts
Normal file
181
packages/cojson/src/coValueCore.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Transaction } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
import { CoMap, MapOpPayload } from "./coValues/coMap.js";
|
||||
import { AccountID } from "./index.js";
|
||||
import { Role } from "./permissions.js";
|
||||
|
||||
test("Can create coValue with new agent credentials and add transaction to it", () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.currentSessionID,
|
||||
[transaction]
|
||||
);
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.currentSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(account.currentSignerSecret(), expectedNewHash)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("transactions with wrong signature are rejected", () => {
|
||||
const wrongAgent = newRandomAgentSecret();
|
||||
const [agentSecret, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(agentSecret, sessionID);
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.currentSessionID,
|
||||
[transaction]
|
||||
);
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.currentSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(getAgentSignerSecret(wrongAgent), expectedNewHash)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.currentSessionID,
|
||||
[
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "wrong",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.currentSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(account.currentSignerSecret(), expectedNewHash)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("New transactions in a group correctly update owned values, including subscriptions", async () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
const timeBeforeEdit = Date.now();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
let map = group.createMap();
|
||||
|
||||
let mapAfterEdit = map.edit((map) => {
|
||||
map.set("hello", "world");
|
||||
});
|
||||
|
||||
const listener = jest.fn().mockImplementation();
|
||||
|
||||
map.subscribe(listener);
|
||||
|
||||
expect(listener.mock.calls[0][0].get("hello")).toBe("world");
|
||||
|
||||
const resignationThatWeJustLearnedAbout = {
|
||||
privacy: "trusting",
|
||||
madeAt: timeBeforeEdit,
|
||||
changes: [
|
||||
{
|
||||
op: "set",
|
||||
key: account.id,
|
||||
value: "revoked"
|
||||
} satisfies MapOpPayload<typeof account.id, Role>
|
||||
]
|
||||
} satisfies Transaction;
|
||||
|
||||
const { expectedNewHash } = group.underlyingMap.core.expectedNewHashAfter(sessionID, [
|
||||
resignationThatWeJustLearnedAbout,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
node.account.currentSignerSecret(),
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
expect(map.core.getValidSortedTransactions().length).toBe(1);
|
||||
|
||||
const manuallyAdddedTxSuccess = group.underlyingMap.core.tryAddTransactions(node.currentSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
|
||||
|
||||
expect(manuallyAdddedTxSuccess).toBe(true);
|
||||
|
||||
expect(listener.mock.calls.length).toBe(2);
|
||||
expect(listener.mock.calls[1][0].get("hello")).toBe(undefined);
|
||||
|
||||
expect(map.core.getValidSortedTransactions().length).toBe(0);
|
||||
});
|
||||
588
packages/cojson/src/coValueCore.ts
Normal file
588
packages/cojson/src/coValueCore.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
import { CoValueImpl } from "./coValue.js";
|
||||
import { Static } from "./coValues/static.js";
|
||||
import { CoStream } from "./coValues/coStream.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import {
|
||||
Encrypted,
|
||||
Hash,
|
||||
KeySecret,
|
||||
Signature,
|
||||
StreamingHash,
|
||||
unseal,
|
||||
shortHash,
|
||||
sign,
|
||||
verify,
|
||||
encryptForTransaction,
|
||||
decryptForTransaction,
|
||||
KeyID,
|
||||
decryptKeySecret,
|
||||
getAgentSignerID,
|
||||
getAgentSealerID,
|
||||
} from "./crypto.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import {
|
||||
PermissionsDef as RulesetDef,
|
||||
determineValidTransactions,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { LocalNode } from "./node.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";
|
||||
|
||||
export type CoValueHeader = {
|
||||
type: CoValueImpl["type"];
|
||||
ruleset: RulesetDef;
|
||||
meta: JsonObject | null;
|
||||
createdAt: `2${string}` | null;
|
||||
uniqueness: `z${string}` | null;
|
||||
};
|
||||
|
||||
export function idforHeader(header: CoValueHeader): RawCoID {
|
||||
const hash = shortHash(header);
|
||||
return `co_z${hash.slice("shortHash_z".length)}`;
|
||||
}
|
||||
|
||||
export function accountOrAgentIDfromSessionID(
|
||||
sessionID: SessionID
|
||||
): AccountID | AgentID {
|
||||
return sessionID.split("_session")[0] as AccountID | AgentID;
|
||||
}
|
||||
|
||||
export function newRandomSessionID(accountID: AccountID | AgentID): SessionID {
|
||||
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
|
||||
}
|
||||
|
||||
type SessionLog = {
|
||||
transactions: Transaction[];
|
||||
lastHash?: Hash;
|
||||
streamingHash: StreamingHash;
|
||||
lastSignature: Signature;
|
||||
};
|
||||
|
||||
export type PrivateTransaction = {
|
||||
privacy: "private";
|
||||
madeAt: number;
|
||||
keyUsed: KeyID;
|
||||
encryptedChanges: Encrypted<
|
||||
JsonValue[],
|
||||
{ in: RawCoID; tx: TransactionID }
|
||||
>;
|
||||
};
|
||||
|
||||
export type TrustingTransaction = {
|
||||
privacy: "trusting";
|
||||
madeAt: number;
|
||||
changes: JsonValue[];
|
||||
};
|
||||
|
||||
export type Transaction = PrivateTransaction | TrustingTransaction;
|
||||
|
||||
export type DecryptedTransaction = {
|
||||
txID: TransactionID;
|
||||
changes: JsonValue[];
|
||||
madeAt: number;
|
||||
};
|
||||
|
||||
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
|
||||
|
||||
export class CoValueCore {
|
||||
id: RawCoID;
|
||||
node: LocalNode;
|
||||
header: CoValueHeader;
|
||||
_sessions: { [key: SessionID]: SessionLog };
|
||||
_cachedContent?: CoValueImpl;
|
||||
listeners: Set<(content?: CoValueImpl) => void> = new Set();
|
||||
|
||||
constructor(
|
||||
header: CoValueHeader,
|
||||
node: LocalNode,
|
||||
internalInitSessions: { [key: SessionID]: SessionLog } = {}
|
||||
) {
|
||||
this.id = idforHeader(header);
|
||||
this.header = header;
|
||||
this._sessions = internalInitSessions;
|
||||
this.node = node;
|
||||
|
||||
if (header.ruleset.type == "ownedByGroup") {
|
||||
this.node
|
||||
.expectCoValueLoaded(header.ruleset.group)
|
||||
.subscribe((_groupUpdate) => {
|
||||
this._cachedContent = undefined;
|
||||
const newContent = this.getCurrentContent();
|
||||
for (const listener of this.listeners) {
|
||||
listener(newContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get sessions(): Readonly<{ [key: SessionID]: SessionLog }> {
|
||||
return this._sessions;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
currentSessionID: SessionID
|
||||
): CoValueCore {
|
||||
const newNode = this.node.testWithDifferentAccount(
|
||||
account,
|
||||
currentSessionID
|
||||
);
|
||||
|
||||
return newNode.expectCoValueLoaded(this.id);
|
||||
}
|
||||
|
||||
knownState(): CoValueKnownState {
|
||||
return {
|
||||
id: this.id,
|
||||
header: true,
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(this.sessions).map(([k, v]) => [
|
||||
k,
|
||||
v.transactions.length,
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
get meta(): JsonValue {
|
||||
return this.header?.meta ?? null;
|
||||
}
|
||||
|
||||
nextTransactionID(): TransactionID {
|
||||
const sessionID = this.node.currentSessionID;
|
||||
return {
|
||||
sessionID,
|
||||
txIndex: this.sessions[sessionID]?.transactions.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
tryAddTransactions(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
givenExpectedNewHash: Hash | undefined,
|
||||
newSignature: Signature
|
||||
): boolean {
|
||||
const signerID = getAgentSignerID(
|
||||
this.node.resolveAccountAgent(
|
||||
accountOrAgentIDfromSessionID(sessionID),
|
||||
"Expected to know signer of transaction"
|
||||
)
|
||||
);
|
||||
|
||||
if (!signerID) {
|
||||
console.warn(
|
||||
"Unknown agent",
|
||||
accountOrAgentIDfromSessionID(sessionID)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
|
||||
sessionID,
|
||||
newTransactions
|
||||
);
|
||||
|
||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
||||
console.warn("Invalid hash", {
|
||||
expectedNewHash,
|
||||
givenExpectedNewHash,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!verify(newSignature, expectedNewHash, signerID)) {
|
||||
console.warn(
|
||||
"Invalid signature",
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
signerID
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
||||
|
||||
transactions.push(...newTransactions);
|
||||
|
||||
this._sessions[sessionID] = {
|
||||
transactions,
|
||||
lastHash: expectedNewHash,
|
||||
streamingHash: newStreamingHash,
|
||||
lastSignature: newSignature,
|
||||
};
|
||||
|
||||
this._cachedContent = undefined;
|
||||
|
||||
const content = this.getCurrentContent();
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
listener(content);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
subscribe(listener: (content?: CoValueImpl) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getCurrentContent());
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
expectedNewHashAfter(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[]
|
||||
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
|
||||
const streamingHash =
|
||||
this.sessions[sessionID]?.streamingHash.clone() ??
|
||||
new StreamingHash();
|
||||
for (const transaction of newTransactions) {
|
||||
streamingHash.update(transaction);
|
||||
}
|
||||
|
||||
const newStreamingHash = streamingHash.clone();
|
||||
|
||||
return {
|
||||
expectedNewHash: streamingHash.digest(),
|
||||
newStreamingHash,
|
||||
};
|
||||
}
|
||||
|
||||
makeTransaction(
|
||||
changes: JsonValue[],
|
||||
privacy: "private" | "trusting"
|
||||
): boolean {
|
||||
const madeAt = Date.now();
|
||||
|
||||
let transaction: Transaction;
|
||||
|
||||
if (privacy === "private") {
|
||||
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
|
||||
|
||||
if (!keySecret) {
|
||||
throw new Error(
|
||||
"Can't make transaction without read key secret"
|
||||
);
|
||||
}
|
||||
|
||||
transaction = {
|
||||
privacy: "private",
|
||||
madeAt,
|
||||
keyUsed: keyID,
|
||||
encryptedChanges: encryptForTransaction(changes, keySecret, {
|
||||
in: this.id,
|
||||
tx: this.nextTransactionID(),
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt,
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionID = this.node.currentSessionID;
|
||||
|
||||
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
|
||||
transaction,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
this.node.account.currentSignerSecret(),
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
const success = this.tryAddTransactions(
|
||||
sessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
signature
|
||||
);
|
||||
|
||||
if (success) {
|
||||
void this.node.sync.syncCoValue(this);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
getCurrentContent(): CoValueImpl {
|
||||
if (this._cachedContent) {
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
if (this.header.type === "comap") {
|
||||
this._cachedContent = new CoMap(this);
|
||||
} else if (this.header.type === "colist") {
|
||||
this._cachedContent = new CoList(this);
|
||||
} else if (this.header.type === "costream") {
|
||||
this._cachedContent = new CoStream(this);
|
||||
} else if (this.header.type === "static") {
|
||||
this._cachedContent = new Static(this);
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||
}
|
||||
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
getValidSortedTransactions(): DecryptedTransaction[] {
|
||||
const validTransactions = determineValidTransactions(this);
|
||||
|
||||
const allTransactions: DecryptedTransaction[] = validTransactions
|
||||
.map(({ txID, tx }) => {
|
||||
if (tx.privacy === "trusting") {
|
||||
return {
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: tx.changes,
|
||||
};
|
||||
} else {
|
||||
const readKey = this.getReadKey(tx.keyUsed);
|
||||
|
||||
if (!readKey) {
|
||||
return undefined;
|
||||
} else {
|
||||
const decrytedChanges = decryptForTransaction(
|
||||
tx.encryptedChanges,
|
||||
readKey,
|
||||
{
|
||||
in: this.id,
|
||||
tx: txID,
|
||||
}
|
||||
);
|
||||
|
||||
if (!decrytedChanges) {
|
||||
console.error(
|
||||
"Failed to decrypt transaction despite having key"
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: decrytedChanges,
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x);
|
||||
allTransactions.sort(
|
||||
(a, b) =>
|
||||
a.madeAt - b.madeAt ||
|
||||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
|
||||
a.txID.txIndex - b.txID.txIndex
|
||||
);
|
||||
|
||||
return allTransactions;
|
||||
}
|
||||
|
||||
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
|
||||
if (this.header.ruleset.type === "group") {
|
||||
const content = expectGroupContent(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.get("readKey");
|
||||
|
||||
if (!currentKeyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
const secret = this.getReadKey(currentKeyId);
|
||||
|
||||
return {
|
||||
secret: secret,
|
||||
id: currentKeyId,
|
||||
};
|
||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentReadKey();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only groups or values owned by groups have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (readKeyCache.get(this)?.[keyID]) {
|
||||
return readKeyCache.get(this)?.[keyID];
|
||||
}
|
||||
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}`
|
||||
);
|
||||
|
||||
if (readKeyEntry) {
|
||||
const revealer = accountOrAgentIDfromSessionID(
|
||||
readKeyEntry.txID.sessionID
|
||||
);
|
||||
const revealerAgent = this.node.resolveAccountAgent(
|
||||
revealer,
|
||||
"Expected to know revealer"
|
||||
);
|
||||
|
||||
const secret = unseal(
|
||||
readKeyEntry.value,
|
||||
this.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: readKeyEntry.txID,
|
||||
}
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = secret;
|
||||
|
||||
return secret as KeySecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
|
||||
for (const field of content.keys()) {
|
||||
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
|
||||
const encryptingKeyID = field.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret =
|
||||
this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = content.get(field)!;
|
||||
|
||||
const secret = decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = secret;
|
||||
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
console.error(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getReadKey(keyID);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only groups or values owned by groups have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getGroup(): Group {
|
||||
if (this.header.ruleset.type !== "ownedByGroup") {
|
||||
throw new Error("Only values owned by groups have groups");
|
||||
}
|
||||
|
||||
return new Group(
|
||||
expectGroupContent(
|
||||
this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentContent()
|
||||
),
|
||||
this.node
|
||||
);
|
||||
}
|
||||
|
||||
getTx(txID: TransactionID): Transaction | undefined {
|
||||
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
|
||||
}
|
||||
|
||||
newContentSince(
|
||||
knownState: CoValueKnownState | undefined
|
||||
): NewContentMessage | undefined {
|
||||
const newContent: NewContentMessage = {
|
||||
action: "content",
|
||||
id: this.id,
|
||||
header: knownState?.header ? undefined : this.header,
|
||||
new: Object.fromEntries(
|
||||
Object.entries(this.sessions)
|
||||
.map(([sessionID, log]) => {
|
||||
const newTransactions = log.transactions.slice(
|
||||
knownState?.sessions[sessionID as SessionID] || 0
|
||||
);
|
||||
|
||||
if (
|
||||
newTransactions.length === 0 ||
|
||||
!log.lastHash ||
|
||||
!log.lastSignature
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
sessionID,
|
||||
{
|
||||
after:
|
||||
knownState?.sessions[
|
||||
sessionID as SessionID
|
||||
] || 0,
|
||||
newTransactions,
|
||||
lastSignature: log.lastSignature,
|
||||
},
|
||||
];
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x)
|
||||
),
|
||||
};
|
||||
|
||||
if (!newContent.header && Object.keys(newContent.new).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return newContent;
|
||||
}
|
||||
|
||||
getDependedOnCoValues(): RawCoID[] {
|
||||
return this.header.ruleset.type === "group"
|
||||
? expectGroupContent(this.getCurrentContent())
|
||||
.keys()
|
||||
.filter((k): k is AccountID => k.startsWith("co_"))
|
||||
: this.header.ruleset.type === "ownedByGroup"
|
||||
? [this.header.ruleset.group]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { CoID } from "../contentType.js";
|
||||
import { CoValue, accountOrAgentIDfromSessionID } from "../coValue.js";
|
||||
import { CoID, ReadableCoValue, WriteableCoValue } from "../coValue.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { SessionID, TransactionID } from "../ids.js";
|
||||
import { AccountID } from "../index.js";
|
||||
import { AccountID, Group } from "../index.js";
|
||||
import { isAccountID } from "../account.js";
|
||||
|
||||
type OpID = TransactionID & { changeIdx: number };
|
||||
@@ -39,15 +39,17 @@ type DeletionEntry = {
|
||||
deletionID: OpID;
|
||||
} & DeletionOpPayload;
|
||||
|
||||
export class CoList<
|
||||
T extends JsonValue,
|
||||
Meta extends JsonObject | null = null
|
||||
> {
|
||||
export class CoList<T extends JsonValue, Meta extends JsonObject | null = null>
|
||||
implements ReadableCoValue
|
||||
{
|
||||
id: CoID<CoList<T, Meta>>;
|
||||
type = "colist" as const;
|
||||
coValue: CoValue;
|
||||
core: CoValueCore;
|
||||
/** @internal */
|
||||
afterStart: OpID[];
|
||||
/** @internal */
|
||||
beforeEnd: OpID[];
|
||||
/** @internal */
|
||||
insertions: {
|
||||
[sessionID: SessionID]: {
|
||||
[txIdx: number]: {
|
||||
@@ -55,6 +57,7 @@ export class CoList<
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @internal */
|
||||
deletionsByInsertion: {
|
||||
[deletedSessionID: SessionID]: {
|
||||
[deletedTxIdx: number]: {
|
||||
@@ -63,9 +66,10 @@ export class CoList<
|
||||
};
|
||||
};
|
||||
|
||||
constructor(coValue: CoValue) {
|
||||
this.id = coValue.id as CoID<CoList<T, Meta>>;
|
||||
this.coValue = coValue;
|
||||
/** @internal */
|
||||
constructor(core: CoValueCore) {
|
||||
this.id = core.id as CoID<CoList<T, Meta>>;
|
||||
this.core = core;
|
||||
this.afterStart = [];
|
||||
this.beforeEnd = [];
|
||||
this.insertions = {};
|
||||
@@ -74,11 +78,15 @@ export class CoList<
|
||||
this.fillOpsFromCoValue();
|
||||
}
|
||||
|
||||
|
||||
get meta(): Meta {
|
||||
return this.coValue.header.meta as Meta;
|
||||
return this.core.header.meta as Meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.core.getGroup();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
protected fillOpsFromCoValue() {
|
||||
this.insertions = {};
|
||||
this.deletionsByInsertion = {};
|
||||
@@ -89,7 +97,7 @@ export class CoList<
|
||||
txID,
|
||||
changes,
|
||||
madeAt,
|
||||
} of this.coValue.getValidSortedTransactions()) {
|
||||
} of this.core.getValidSortedTransactions()) {
|
||||
for (const [changeIdx, changeUntyped] of changes.entries()) {
|
||||
const change = changeUntyped as ListOpPayload<T>;
|
||||
|
||||
@@ -191,6 +199,20 @@ export class CoList<
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the item currently at `idx`. */
|
||||
get(idx: number): T | undefined {
|
||||
const entry = this.entries()[idx];
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/** Returns the current items in the CoList as an array. */
|
||||
asArray(): T[] {
|
||||
return this.entries().map((entry) => entry.value);
|
||||
}
|
||||
|
||||
entries(): { value: T; madeAt: number; opID: OpID }[] {
|
||||
const arr: { value: T; madeAt: number; opID: OpID }[] = [];
|
||||
for (const opID of this.afterStart) {
|
||||
@@ -202,6 +224,7 @@ export class CoList<
|
||||
return arr;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
private fillArrayFromOpID(
|
||||
opID: OpID,
|
||||
arr: { value: T; madeAt: number; opID: OpID }[]
|
||||
@@ -230,7 +253,8 @@ export class CoList<
|
||||
}
|
||||
}
|
||||
|
||||
getLastEditor(idx: number): AccountID | undefined {
|
||||
/** Returns the accountID of the account that inserted value at the given index. */
|
||||
whoInserted(idx: number): AccountID | undefined {
|
||||
const entry = this.entries()[idx];
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
@@ -243,33 +267,66 @@ export class CoList<
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the current items in the CoList as an array. (alias of `asArray`) */
|
||||
toJSON(): T[] {
|
||||
return this.asArray();
|
||||
}
|
||||
|
||||
asArray(): T[] {
|
||||
return this.entries().map((entry) => entry.value);
|
||||
map<U>(mapper: (value: T, idx: number) => U): U[] {
|
||||
return this.entries().map((entry, idx) => mapper(entry.value, idx));
|
||||
}
|
||||
|
||||
filter<U extends T>(predicate: (value: T, idx: number) => value is U): U[];
|
||||
filter(predicate: (value: T, idx: number) => boolean): T[] {
|
||||
return this.entries()
|
||||
.filter((entry, idx) => predicate(entry.value, idx))
|
||||
.map((entry) => entry.value);
|
||||
}
|
||||
|
||||
reduce<U>(
|
||||
reducer: (accumulator: U, value: T, idx: number) => U,
|
||||
initialValue: U
|
||||
): U {
|
||||
return this.entries().reduce(
|
||||
(accumulator, entry, idx) => reducer(accumulator, entry.value, idx),
|
||||
initialValue
|
||||
);
|
||||
}
|
||||
|
||||
subscribe(listener: (coMap: CoList<T, Meta>) => void): () => void {
|
||||
return this.core.subscribe((content) => {
|
||||
listener(content as CoList<T, Meta>);
|
||||
});
|
||||
}
|
||||
|
||||
edit(
|
||||
changer: (editable: WriteableCoList<T, Meta>) => void
|
||||
): CoList<T, Meta> {
|
||||
const editable = new WriteableCoList<T, Meta>(this.coValue);
|
||||
const editable = new WriteableCoList<T, Meta>(this.core);
|
||||
changer(editable);
|
||||
return new CoList(this.coValue);
|
||||
}
|
||||
|
||||
subscribe(listener: (coMap: CoList<T, Meta>) => void): () => void {
|
||||
return this.coValue.subscribe((content) => {
|
||||
listener(content as CoList<T, Meta>);
|
||||
});
|
||||
return new CoList(this.core);
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableCoList<
|
||||
T extends JsonValue,
|
||||
Meta extends JsonObject | null = null
|
||||
> extends CoList<T, Meta> {
|
||||
T extends JsonValue,
|
||||
Meta extends JsonObject | null = null
|
||||
>
|
||||
extends CoList<T, Meta>
|
||||
implements WriteableCoValue
|
||||
{
|
||||
/** @internal */
|
||||
edit(
|
||||
_changer: (editable: WriteableCoList<T, Meta>) => void
|
||||
): CoList<T, Meta> {
|
||||
throw new Error("Already editing.");
|
||||
}
|
||||
|
||||
/** Appends a new item after 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 `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
|
||||
append(
|
||||
after: number,
|
||||
value: T,
|
||||
@@ -289,7 +346,7 @@ export class WriteableCoList<
|
||||
}
|
||||
opIDBefore = "start";
|
||||
}
|
||||
this.coValue.makeTransaction(
|
||||
this.core.makeTransaction(
|
||||
[
|
||||
{
|
||||
op: "app",
|
||||
@@ -303,12 +360,28 @@ export class WriteableCoList<
|
||||
this.fillOpsFromCoValue();
|
||||
}
|
||||
|
||||
/** Pushes a new item to the end of the list.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, both `value` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
|
||||
push(value: T, privacy: "private" | "trusting" = "private"): void {
|
||||
// TODO: optimize
|
||||
const entries = this.entries();
|
||||
this.append(entries.length > 0 ? entries.length - 1 : 0, value, privacy);
|
||||
this.append(
|
||||
entries.length > 0 ? entries.length - 1 : 0,
|
||||
value,
|
||||
privacy
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepends a new item before 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 `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
*/
|
||||
prepend(
|
||||
before: number,
|
||||
value: T,
|
||||
@@ -332,7 +405,7 @@ export class WriteableCoList<
|
||||
}
|
||||
opIDAfter = "end";
|
||||
}
|
||||
this.coValue.makeTransaction(
|
||||
this.core.makeTransaction(
|
||||
[
|
||||
{
|
||||
op: "pre",
|
||||
@@ -346,16 +419,18 @@ export class WriteableCoList<
|
||||
this.fillOpsFromCoValue();
|
||||
}
|
||||
|
||||
delete(
|
||||
at: number,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
/** 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. */
|
||||
delete(at: number, privacy: "private" | "trusting" = "private"): void {
|
||||
const entries = this.entries();
|
||||
const entry = entries[at];
|
||||
if (!entry) {
|
||||
throw new Error("Invalid index " + at);
|
||||
}
|
||||
this.coValue.makeTransaction(
|
||||
this.core.makeTransaction(
|
||||
[
|
||||
{
|
||||
op: "del",
|
||||
@@ -1,8 +1,9 @@
|
||||
import { JsonObject, JsonValue } from '../jsonValue.js';
|
||||
import { TransactionID } from '../ids.js';
|
||||
import { CoID } from '../contentType.js';
|
||||
import { CoValue, accountOrAgentIDfromSessionID } from '../coValue.js';
|
||||
import { CoID, ReadableCoValue, WriteableCoValue } from '../coValue.js';
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from '../coValueCore.js';
|
||||
import { AccountID, isAccountID } from '../account.js';
|
||||
import { Group } from '../group.js';
|
||||
|
||||
type MapOp<K extends string, V extends JsonValue> = {
|
||||
txID: TransactionID;
|
||||
@@ -27,33 +28,41 @@ export type MapM<M extends { [key: string]: JsonValue; }> = {
|
||||
[KK in MapK<M>]: M[KK];
|
||||
}
|
||||
|
||||
/** A collaborative map with precise shape `M` and optional static metadata `Meta` */
|
||||
export class CoMap<
|
||||
M extends { [key: string]: JsonValue; },
|
||||
Meta extends JsonObject | null = null,
|
||||
> {
|
||||
> implements ReadableCoValue {
|
||||
id: CoID<CoMap<MapM<M>, Meta>>;
|
||||
coValue: CoValue;
|
||||
type = "comap" as const;
|
||||
core: CoValueCore;
|
||||
/** @internal */
|
||||
ops: {
|
||||
[KK in MapK<M>]?: MapOp<KK, M[KK]>[];
|
||||
};
|
||||
|
||||
constructor(coValue: CoValue) {
|
||||
this.id = coValue.id as CoID<CoMap<MapM<M>, Meta>>;
|
||||
this.coValue = coValue;
|
||||
/** @internal */
|
||||
constructor(core: CoValueCore) {
|
||||
this.id = core.id as CoID<CoMap<MapM<M>, Meta>>;
|
||||
this.core = core;
|
||||
this.ops = {};
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
}
|
||||
|
||||
get meta(): Meta {
|
||||
return this.coValue.header.meta as 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.coValue.getValidSortedTransactions()) {
|
||||
for (const { txID, changes, madeAt } of this.core.getValidSortedTransactions()) {
|
||||
for (const [changeIdx, changeUntyped] of (
|
||||
changes
|
||||
).entries()) {
|
||||
@@ -77,6 +86,7 @@ export class CoMap<
|
||||
return Object.keys(this.ops) as MapK<M>[];
|
||||
}
|
||||
|
||||
/** Returns the current value for the given key. */
|
||||
get<K extends MapK<M>>(key: K): M[K] | undefined {
|
||||
const ops = this.ops[key];
|
||||
if (!ops) {
|
||||
@@ -111,7 +121,8 @@ export class CoMap<
|
||||
}
|
||||
}
|
||||
|
||||
getLastEditor<K extends MapK<M>>(key: K): AccountID | undefined {
|
||||
/** Returns the accountID of the last account to modify the value for the given key. */
|
||||
whoEdited<K extends MapK<M>>(key: K): AccountID | undefined {
|
||||
const tx = this.getLastTxID(key);
|
||||
if (!tx) {
|
||||
return undefined;
|
||||
@@ -182,26 +193,35 @@ export class CoMap<
|
||||
return json;
|
||||
}
|
||||
|
||||
edit(changer: (editable: WriteableCoMap<M, Meta>) => void): CoMap<M, Meta> {
|
||||
const editable = new WriteableCoMap<M, Meta>(this.coValue);
|
||||
changer(editable);
|
||||
return new CoMap(this.coValue);
|
||||
}
|
||||
|
||||
subscribe(listener: (coMap: CoMap<M, Meta>) => void): () => void {
|
||||
return this.coValue.subscribe((content) => {
|
||||
return this.core.subscribe((content) => {
|
||||
listener(content as CoMap<M, Meta>);
|
||||
});
|
||||
}
|
||||
|
||||
edit(changer: (editable: WriteableCoMap<M, Meta>) => void): CoMap<M, Meta> {
|
||||
const editable = new WriteableCoMap<M, Meta>(this.core);
|
||||
changer(editable);
|
||||
return new CoMap(this.core);
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableCoMap<
|
||||
M extends { [key: string]: JsonValue; },
|
||||
Meta extends JsonObject | null = null,
|
||||
> extends CoMap<M, Meta> implements WriteableCoValue {
|
||||
/** @internal */
|
||||
edit(_changer: (editable: WriteableCoMap<M, Meta>) => void): CoMap<M, Meta> {
|
||||
throw new Error("Already editing.");
|
||||
}
|
||||
|
||||
> extends CoMap<M, Meta> {
|
||||
/** Sets a new value for the given key.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, both `key` and `value` are encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, both `key` and `value` are stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
|
||||
set<K extends MapK<M>>(key: K, value: M[K], privacy: "private" | "trusting" = "private"): void {
|
||||
this.coValue.makeTransaction([
|
||||
this.core.makeTransaction([
|
||||
{
|
||||
op: "set",
|
||||
key,
|
||||
@@ -212,8 +232,13 @@ export class WriteableCoMap<
|
||||
this.fillOpsFromCoValue();
|
||||
}
|
||||
|
||||
/** Deletes the value for the given key (setting it to undefined).
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, `key` is encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
|
||||
*
|
||||
* If `privacy` is `"trusting"`, `key` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
|
||||
delete(key: MapK<M>, privacy: "private" | "trusting" = "private"): void {
|
||||
this.coValue.makeTransaction([
|
||||
this.core.makeTransaction([
|
||||
{
|
||||
op: "del",
|
||||
key,
|
||||
33
packages/cojson/src/coValues/coStream.ts
Normal file
33
packages/cojson/src/coValues/coStream.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { JsonObject, JsonValue } from '../jsonValue.js';
|
||||
import { CoID, ReadableCoValue } from '../coValue.js';
|
||||
import { CoValueCore } from '../coValueCore.js';
|
||||
import { Group } from '../index.js';
|
||||
|
||||
export class CoStream<T extends JsonValue, Meta extends JsonObject | null = null> implements ReadableCoValue {
|
||||
id: CoID<CoStream<T, Meta>>;
|
||||
type = "costream" as const;
|
||||
core: CoValueCore;
|
||||
|
||||
constructor(core: CoValueCore) {
|
||||
this.id = core.id as CoID<CoStream<T, Meta>>;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
get meta(): Meta {
|
||||
return this.core.header.meta as Meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.core.getGroup();
|
||||
}
|
||||
|
||||
toJSON(): JsonObject {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
subscribe(listener: (coMap: CoStream<T, Meta>) => void): () => void {
|
||||
return this.core.subscribe((content) => {
|
||||
listener(content as CoStream<T, Meta>);
|
||||
});
|
||||
}
|
||||
}
|
||||
31
packages/cojson/src/coValues/static.ts
Normal file
31
packages/cojson/src/coValues/static.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { JsonObject } from '../jsonValue.js';
|
||||
import { CoID, ReadableCoValue } from '../coValue.js';
|
||||
import { CoValueCore } from '../coValueCore.js';
|
||||
import { Group } from '../index.js';
|
||||
|
||||
export class Static<T extends JsonObject> implements ReadableCoValue{
|
||||
id: CoID<Static<T>>;
|
||||
type = "static" as const;
|
||||
core: CoValueCore;
|
||||
|
||||
constructor(core: CoValueCore) {
|
||||
this.id = core.id as CoID<Static<T>>;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
get meta(): T {
|
||||
return this.core.header.meta as T;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.core.getGroup();
|
||||
}
|
||||
|
||||
toJSON(): JsonObject {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
subscribe(_listener: (coMap: Static<T>) => void): () => void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import { accountOrAgentIDfromSessionID } from "./coValue.js";
|
||||
import { createdNowUnique } from "./crypto.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
|
||||
test("Empty CoMap works", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
expect([...content.keys()]).toEqual([]);
|
||||
expect(content.toJSON()).toEqual({});
|
||||
});
|
||||
|
||||
test("Can insert and delete CoMap entries in edit()", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set("hello", "world", "trusting");
|
||||
expect(editable.get("hello")).toEqual("world");
|
||||
editable.set("foo", "bar", "trusting");
|
||||
expect(editable.get("foo")).toEqual("bar");
|
||||
expect([...editable.keys()]).toEqual(["hello", "foo"]);
|
||||
editable.delete("foo", "trusting");
|
||||
expect(editable.get("foo")).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can get CoMap entry values at different points in time", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
const beforeA = Date.now();
|
||||
while (Date.now() < beforeA + 10) {}
|
||||
editable.set("hello", "A", "trusting");
|
||||
const beforeB = Date.now();
|
||||
while (Date.now() < beforeB + 10) {}
|
||||
editable.set("hello", "B", "trusting");
|
||||
const beforeC = Date.now();
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
test("Can get all historic values of key in CoMap", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set("hello", "A", "trusting");
|
||||
const txA = editable.getLastTxID("hello");
|
||||
editable.set("hello", "B", "trusting");
|
||||
const txB = editable.getLastTxID("hello");
|
||||
editable.delete("hello", "trusting");
|
||||
const txDel = editable.getLastTxID("hello");
|
||||
editable.set("hello", "C", "trusting");
|
||||
const txC = editable.getLastTxID("hello");
|
||||
expect(editable.getHistory("hello")).toEqual([
|
||||
{
|
||||
txID: txA,
|
||||
value: "A",
|
||||
at: txA && coValue.getTx(txA)?.madeAt,
|
||||
},
|
||||
{
|
||||
txID: txB,
|
||||
value: "B",
|
||||
at: txB && coValue.getTx(txB)?.madeAt,
|
||||
},
|
||||
{
|
||||
txID: txDel,
|
||||
value: undefined,
|
||||
at: txDel && coValue.getTx(txDel)?.madeAt,
|
||||
},
|
||||
{
|
||||
txID: txC,
|
||||
value: "C",
|
||||
at: txC && coValue.getTx(txC)?.madeAt,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can get last tx ID for a key in CoMap", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
expect(editable.getLastTxID("hello")).toEqual(undefined);
|
||||
editable.set("hello", "A", "trusting");
|
||||
const sessionID = editable.getLastTxID("hello")?.sessionID;
|
||||
expect(sessionID && accountOrAgentIDfromSessionID(sessionID)).toEqual(
|
||||
node.account.id
|
||||
);
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(0);
|
||||
editable.set("hello", "B", "trusting");
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(1);
|
||||
editable.set("hello", "C", "trusting");
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test("Empty CoList works", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
expect(content.toJSON()).toEqual([]);
|
||||
});
|
||||
|
||||
test("Can append, prepend and delete items to CoList", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.append(0, "hello", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello"]);
|
||||
editable.append(0, "world", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "world"]);
|
||||
editable.prepend(1, "beautiful", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "beautiful", "world"]);
|
||||
editable.prepend(3, "hooray", "trusting");
|
||||
expect(editable.toJSON()).toEqual([
|
||||
"hello",
|
||||
"beautiful",
|
||||
"world",
|
||||
"hooray",
|
||||
]);
|
||||
editable.delete(2, "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "beautiful", "hooray"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("Push is equivalent to append after last item", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.append(0, "hello", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello"]);
|
||||
editable.push("world", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "world"]);
|
||||
editable.push("hooray", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello", "world", "hooray"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can push into empty list", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("colist");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.push("hello", "trusting");
|
||||
expect(editable.toJSON()).toEqual(["hello"]);
|
||||
});
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { RawCoID } from "./ids.js";
|
||||
import { CoMap } from "./contentTypes/coMap.js";
|
||||
import { CoStream } from "./contentTypes/coStream.js";
|
||||
import { Static } from "./contentTypes/static.js";
|
||||
import { CoList } from "./contentTypes/coList.js";
|
||||
|
||||
export type CoID<T extends ContentType> = RawCoID & {
|
||||
readonly __type: T;
|
||||
};
|
||||
|
||||
export type ContentType =
|
||||
| CoMap<{ [key: string]: JsonValue }, JsonObject | null>
|
||||
| CoList<JsonValue, JsonObject | null>
|
||||
| CoStream<JsonValue, JsonObject | null>
|
||||
| Static<JsonObject>;
|
||||
|
||||
export function expectMap(
|
||||
content: ContentType
|
||||
): CoMap<{ [key: string]: string }, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<{ [key: string]: string }, JsonObject | null>;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { JsonObject, JsonValue } from '../jsonValue.js';
|
||||
import { CoID } from '../contentType.js';
|
||||
import { CoValue } from '../coValue.js';
|
||||
|
||||
export class CoStream<T extends JsonValue, Meta extends JsonObject | null = null> {
|
||||
id: CoID<CoStream<T, Meta>>;
|
||||
type = "costream" as const;
|
||||
coValue: CoValue;
|
||||
|
||||
constructor(coValue: CoValue) {
|
||||
this.id = coValue.id as CoID<CoStream<T, Meta>>;
|
||||
this.coValue = coValue;
|
||||
}
|
||||
|
||||
toJSON(): JsonObject {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
subscribe(listener: (coMap: CoStream<T, Meta>) => void): () => void {
|
||||
return this.coValue.subscribe((content) => {
|
||||
listener(content as CoStream<T, Meta>);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { JsonObject } from '../jsonValue.js';
|
||||
import { CoID } from '../contentType.js';
|
||||
import { CoValue } from '../coValue.js';
|
||||
|
||||
export class Static<T extends JsonObject> {
|
||||
id: CoID<Static<T>>;
|
||||
type = "static" as const;
|
||||
coValue: CoValue;
|
||||
|
||||
constructor(coValue: CoValue) {
|
||||
this.id = coValue.id as CoID<Static<T>>;
|
||||
this.coValue = coValue;
|
||||
}
|
||||
|
||||
toJSON(): JsonObject {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
subscribe(_listener: (coMap: Static<T>) => void): () => void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
import { CoMap } from "./contentTypes/coMap.js";
|
||||
import { CoID, CoValueImpl } from "./coValue.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
Encrypted,
|
||||
@@ -16,21 +16,17 @@ import {
|
||||
getAgentID,
|
||||
} from "./crypto.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { SessionID, isAgentID } from "./ids.js";
|
||||
import {
|
||||
AccountIDOrAgentID,
|
||||
GeneralizedControlledAccount,
|
||||
Profile,
|
||||
} from "./account.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 "./contentTypes/coList.js";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
|
||||
export type GroupContent = {
|
||||
profile: CoID<Profile> | null;
|
||||
[key: AccountIDOrAgentID]: Role;
|
||||
[key: AccountID | AgentID]: Role;
|
||||
readKey: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
|
||||
[revelationFor: `${KeyID}_for_${AccountID | AgentID}`]: Sealed<KeySecret>;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
@@ -38,7 +34,7 @@ export type GroupContent = {
|
||||
};
|
||||
|
||||
export function expectGroupContent(
|
||||
content: ContentType
|
||||
content: CoValueImpl
|
||||
): CoMap<GroupContent, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
@@ -47,33 +43,71 @@ export function expectGroupContent(
|
||||
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 {
|
||||
groupMap: CoMap<GroupContent, JsonObject | null>;
|
||||
underlyingMap: CoMap<GroupContent, JsonObject | null>;
|
||||
/** @internal */
|
||||
node: LocalNode;
|
||||
|
||||
/** @internal */
|
||||
constructor(
|
||||
groupMap: CoMap<GroupContent, JsonObject | null>,
|
||||
underlyingMap: CoMap<GroupContent, JsonObject | null>,
|
||||
node: LocalNode
|
||||
) {
|
||||
this.groupMap = groupMap;
|
||||
this.underlyingMap = underlyingMap;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
/** Returns the `CoID` of the `Group`. */
|
||||
get id(): CoID<CoMap<GroupContent, JsonObject | null>> {
|
||||
return this.groupMap.id;
|
||||
return this.underlyingMap.id;
|
||||
}
|
||||
|
||||
roleOf(accountID: AccountIDOrAgentID): Role | undefined {
|
||||
return this.groupMap.get(accountID);
|
||||
/** 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.roleOf(this.node.account.id);
|
||||
return this.roleOfInternal(this.node.account.id);
|
||||
}
|
||||
|
||||
addMember(accountID: AccountIDOrAgentID, role: Role) {
|
||||
this.groupMap = this.groupMap.edit((map) => {
|
||||
const currentReadKey = this.groupMap.coValue.getCurrentReadKey();
|
||||
/** 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");
|
||||
@@ -94,11 +128,11 @@ export class Group {
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal(
|
||||
currentReadKey.secret,
|
||||
this.groupMap.coValue.node.account.currentSealerSecret(),
|
||||
this.underlyingMap.core.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(agent),
|
||||
{
|
||||
in: this.groupMap.coValue.id,
|
||||
tx: this.groupMap.coValue.nextTransactionID(),
|
||||
in: this.underlyingMap.core.id,
|
||||
tx: this.underlyingMap.core.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
@@ -106,30 +140,24 @@ export class Group {
|
||||
});
|
||||
}
|
||||
|
||||
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
|
||||
const secretSeed = newRandomSecretSeed();
|
||||
|
||||
const inviteSecret = agentSecretFromSecretSeed(secretSeed);
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
this.addMember(inviteID, `${role}Invite` as Role);
|
||||
|
||||
return inviteSecretFromSecretSeed(secretSeed);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
rotateReadKey() {
|
||||
const currentlyPermittedReaders = this.groupMap.keys().filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.groupMap.get(key);
|
||||
return (
|
||||
role === "admin" || role === "writer" || role === "reader"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as AccountIDOrAgentID[];
|
||||
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.groupMap.coValue.getCurrentReadKey();
|
||||
const maybeCurrentReadKey = this.underlyingMap.core.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error(
|
||||
@@ -144,7 +172,7 @@ export class Group {
|
||||
|
||||
const newReadKey = newRandomKeySecret();
|
||||
|
||||
this.groupMap = this.groupMap.edit((map) => {
|
||||
this.underlyingMap = this.underlyingMap.edit((map) => {
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.node.resolveAccountAgent(
|
||||
readerID,
|
||||
@@ -155,11 +183,11 @@ export class Group {
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
seal(
|
||||
newReadKey.secret,
|
||||
this.groupMap.coValue.node.account.currentSealerSecret(),
|
||||
this.underlyingMap.core.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(reader),
|
||||
{
|
||||
in: this.groupMap.coValue.id,
|
||||
tx: this.groupMap.coValue.nextTransactionID(),
|
||||
in: this.underlyingMap.core.id,
|
||||
tx: this.underlyingMap.core.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
@@ -179,14 +207,36 @@ export class Group {
|
||||
});
|
||||
}
|
||||
|
||||
removeMember(accountID: AccountIDOrAgentID) {
|
||||
this.groupMap = this.groupMap.edit((map) => {
|
||||
/** 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 }, JsonObject | null>>(
|
||||
meta?: M["meta"]
|
||||
): M {
|
||||
@@ -195,7 +245,7 @@ export class Group {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.groupMap.id,
|
||||
group: this.underlyingMap.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
@@ -203,6 +253,8 @@ export class Group {
|
||||
.getCurrentContent() as M;
|
||||
}
|
||||
|
||||
/** Creates a new `CoList` within this group, with the specified specialized
|
||||
* `CoList` type `L` and optional static metadata. */
|
||||
createList<L extends CoList<JsonValue, JsonObject | null>>(
|
||||
meta?: L["meta"]
|
||||
): L {
|
||||
@@ -211,7 +263,7 @@ export class Group {
|
||||
type: "colist",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.groupMap.id,
|
||||
group: this.underlyingMap.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
@@ -219,13 +271,14 @@ export class Group {
|
||||
.getCurrentContent() as L;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
sessionId: SessionID
|
||||
): Group {
|
||||
return new Group(
|
||||
expectGroupContent(
|
||||
this.groupMap.coValue
|
||||
this.underlyingMap.core
|
||||
.testWithDifferentAccount(account, sessionId)
|
||||
.getCurrentContent()
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AccountIDOrAgentID } from './account.js';
|
||||
import { AccountID } from './account.js';
|
||||
import { base58 } from "@scure/base";
|
||||
import { shortHashLength } from './crypto.js';
|
||||
|
||||
@@ -23,4 +23,4 @@ export function isAgentID(id: string): id is AgentID {
|
||||
return typeof id === "string" && id.startsWith("sealer_") && id.includes("/signer_");
|
||||
}
|
||||
|
||||
export type SessionID = `${AccountIDOrAgentID}_session_z${string}`;
|
||||
export type SessionID = `${AccountID | AgentID}_session_z${string}`;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { CoValue, newRandomSessionID } from "./coValue.js";
|
||||
import { CoValueCore, newRandomSessionID } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { CoMap } from "./contentTypes/coMap.js";
|
||||
import type { CoValue, ReadableCoValue } from "./coValue.js";
|
||||
import { CoMap, WriteableCoMap } from "./coValues/coMap.js";
|
||||
import { CoList, WriteableCoList } from "./coValues/coList.js";
|
||||
import {
|
||||
agentSecretFromBytes,
|
||||
agentSecretToBytes,
|
||||
@@ -14,24 +16,19 @@ import {
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
|
||||
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
|
||||
import { Group, expectGroupContent } from "./group.js"
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
|
||||
import type { SessionID, AgentID } from "./ids.js";
|
||||
import type { CoID, ContentType } from "./contentType.js";
|
||||
import type { CoID, CoValueImpl } from "./coValue.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type { SyncMessage, Peer } from "./sync.js";
|
||||
import type { AgentSecret } from "./crypto.js";
|
||||
import type {
|
||||
AccountID,
|
||||
AccountContent,
|
||||
ProfileContent,
|
||||
ProfileMeta,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
import type { AccountID, Profile } from "./account.js";
|
||||
import type { InviteSecret } from "./group.js";
|
||||
|
||||
type Value = JsonValue | ContentType;
|
||||
type Value = JsonValue | CoValueImpl;
|
||||
|
||||
/** @hidden */
|
||||
export const cojsonInternals = {
|
||||
agentSecretFromBytes,
|
||||
agentSecretToBytes,
|
||||
@@ -45,34 +42,36 @@ export const cojsonInternals = {
|
||||
agentSecretFromSecretSeed,
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
expectGroupContent
|
||||
expectGroupContent,
|
||||
};
|
||||
|
||||
export {
|
||||
LocalNode,
|
||||
CoValue,
|
||||
Group,
|
||||
CoMap,
|
||||
WriteableCoMap,
|
||||
CoList,
|
||||
WriteableCoList,
|
||||
CoValueCore,
|
||||
AnonymousControlledAccount,
|
||||
ControlledAccount,
|
||||
Group
|
||||
};
|
||||
|
||||
export type {
|
||||
Value,
|
||||
JsonValue,
|
||||
ContentType,
|
||||
CoValue,
|
||||
ReadableCoValue,
|
||||
CoValueImpl,
|
||||
CoID,
|
||||
AgentSecret,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
AgentID,
|
||||
AccountID,
|
||||
Peer,
|
||||
AccountContent,
|
||||
Profile,
|
||||
ProfileContent,
|
||||
ProfileMeta,
|
||||
InviteSecret
|
||||
SessionID,
|
||||
Peer,
|
||||
AgentID,
|
||||
AgentSecret,
|
||||
InviteSecret,
|
||||
SyncMessage,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
@@ -82,9 +81,13 @@ export namespace CojsonInternalTypes {
|
||||
export type KnownStateMessage = import("./sync.js").KnownStateMessage;
|
||||
export type LoadMessage = import("./sync.js").LoadMessage;
|
||||
export type NewContentMessage = import("./sync.js").NewContentMessage;
|
||||
export type CoValueHeader = import("./coValue.js").CoValueHeader;
|
||||
export type Transaction = import("./coValue.js").Transaction;
|
||||
export type CoValueHeader = import("./coValueCore.js").CoValueHeader;
|
||||
export type Transaction = import("./coValueCore.js").Transaction;
|
||||
export type Signature = import("./crypto.js").Signature;
|
||||
export type RawCoID = import("./ids.js").RawCoID;
|
||||
export type AccountIDOrAgentID = import("./account.js").AccountIDOrAgentID;
|
||||
export type AccountContent = import("./account.js").AccountContent;
|
||||
export type ProfileContent = import("./account.js").ProfileContent;
|
||||
export type ProfileMeta = import("./account.js").ProfileMeta;
|
||||
export type SealerSecret = import("./crypto.js").SealerSecret;
|
||||
export type SignerSecret = import("./crypto.js").SignerSecret;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
} from "./crypto.js";
|
||||
import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js";
|
||||
import { CoValueCore, CoValueHeader, newRandomSessionID } from "./coValueCore.js";
|
||||
import {
|
||||
InviteSecret,
|
||||
Group,
|
||||
@@ -19,11 +19,10 @@ import {
|
||||
} from "./group.js";
|
||||
import { Peer, SyncManager } from "./sync.js";
|
||||
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
import { CoID, CoValueImpl } from "./coValue.js";
|
||||
import {
|
||||
Account,
|
||||
AccountMeta,
|
||||
AccountIDOrAgentID,
|
||||
accountHeaderForInitialAgentSecret,
|
||||
GeneralizedControlledAccount,
|
||||
ControlledAccount,
|
||||
@@ -35,18 +34,31 @@ import {
|
||||
} from "./account.js";
|
||||
import { CoMap } from "./index.js";
|
||||
|
||||
/** A `LocalNode` represents a local view of a set of loaded `CoValue`s, from the perspective of a particular account (or primitive cryptographic agent).
|
||||
|
||||
A `LocalNode` can have peers that it syncs to, for example some form of local persistence, or a sync server, such as `sync.jazz.tools` (Jazz Global Mesh).
|
||||
|
||||
@example
|
||||
You typically get hold of a `LocalNode` using `jazz-react`'s `useJazz()`:
|
||||
|
||||
```typescript
|
||||
const { localNode } = useJazz();
|
||||
```
|
||||
*/
|
||||
export class LocalNode {
|
||||
/** @internal */
|
||||
coValues: { [key: RawCoID]: CoValueState } = {};
|
||||
/** @internal */
|
||||
account: GeneralizedControlledAccount;
|
||||
ownSessionID: SessionID;
|
||||
currentSessionID: SessionID;
|
||||
sync = new SyncManager(this);
|
||||
|
||||
constructor(
|
||||
account: GeneralizedControlledAccount,
|
||||
ownSessionID: SessionID
|
||||
currentSessionID: SessionID
|
||||
) {
|
||||
this.account = account;
|
||||
this.ownSessionID = ownSessionID;
|
||||
this.currentSessionID = currentSessionID;
|
||||
}
|
||||
|
||||
static withNewlyCreatedAccount(
|
||||
@@ -75,7 +87,7 @@ export class LocalNode {
|
||||
node: nodeWithAccount,
|
||||
accountID: account.id,
|
||||
accountSecret: account.agentSecret,
|
||||
sessionID: nodeWithAccount.ownSessionID,
|
||||
sessionID: nodeWithAccount.currentSessionID,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,8 +121,9 @@ export class LocalNode {
|
||||
return node;
|
||||
}
|
||||
|
||||
createCoValue(header: CoValueHeader): CoValue {
|
||||
const coValue = new CoValue(header, this);
|
||||
/** @internal */
|
||||
createCoValue(header: CoValueHeader): CoValueCore {
|
||||
const coValue = new CoValueCore(header, this);
|
||||
this.coValues[coValue.id] = { state: "loaded", coValue: coValue };
|
||||
|
||||
void this.sync.syncCoValue(coValue);
|
||||
@@ -118,7 +131,8 @@ export class LocalNode {
|
||||
return coValue;
|
||||
}
|
||||
|
||||
loadCoValue(id: RawCoID): Promise<CoValue> {
|
||||
/** @internal */
|
||||
loadCoValue(id: RawCoID): Promise<CoValueCore> {
|
||||
let entry = this.coValues[id];
|
||||
if (!entry) {
|
||||
entry = newLoadingState();
|
||||
@@ -133,10 +147,19 @@ export class LocalNode {
|
||||
return entry.done;
|
||||
}
|
||||
|
||||
async load<T extends ContentType>(id: CoID<T>): Promise<T> {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async load<T extends CoValueImpl>(id: CoID<T>): Promise<T> {
|
||||
return (await this.loadCoValue(id)).getCurrentContent() as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a profile associated with an account. `Profile` is at least a `CoMap<{string: name}>`,
|
||||
* but might contain other, app-specific properties.
|
||||
*/
|
||||
async loadProfile(id: AccountID): Promise<Profile> {
|
||||
const account = await this.load<AccountMap>(id);
|
||||
const profileID = account.get("profile");
|
||||
@@ -149,20 +172,20 @@ export class LocalNode {
|
||||
).getCurrentContent() as Profile;
|
||||
}
|
||||
|
||||
async acceptInvite<T extends ContentType>(
|
||||
async acceptInvite<T extends CoValueImpl>(
|
||||
groupOrOwnedValueID: CoID<T>,
|
||||
inviteSecret: InviteSecret
|
||||
): Promise<void> {
|
||||
const groupOrOwnedValue = await this.load(groupOrOwnedValueID);
|
||||
|
||||
if (groupOrOwnedValue.coValue.header.ruleset.type === "ownedByGroup") {
|
||||
if (groupOrOwnedValue.core.header.ruleset.type === "ownedByGroup") {
|
||||
return this.acceptInvite(
|
||||
groupOrOwnedValue.coValue.header.ruleset.group as CoID<
|
||||
groupOrOwnedValue.core.header.ruleset.group as CoID<
|
||||
CoMap<GroupContent>
|
||||
>,
|
||||
inviteSecret
|
||||
);
|
||||
} else if (groupOrOwnedValue.coValue.header.ruleset.type !== "group") {
|
||||
} else if (groupOrOwnedValue.core.header.ruleset.type !== "group") {
|
||||
throw new Error("Can only accept invites to groups");
|
||||
}
|
||||
|
||||
@@ -174,7 +197,7 @@ export class LocalNode {
|
||||
const inviteAgentID = getAgentID(inviteAgentSecret);
|
||||
|
||||
const inviteRole = await new Promise((resolve, reject) => {
|
||||
group.groupMap.subscribe((groupMap) => {
|
||||
group.underlyingMap.subscribe((groupMap) => {
|
||||
const role = groupMap.get(inviteAgentID);
|
||||
if (role) {
|
||||
resolve(role);
|
||||
@@ -193,7 +216,7 @@ export class LocalNode {
|
||||
throw new Error("No invite found");
|
||||
}
|
||||
|
||||
const existingRole = group.groupMap.get(this.account.id);
|
||||
const existingRole = group.underlyingMap.get(this.account.id);
|
||||
|
||||
if (
|
||||
existingRole === "admin" ||
|
||||
@@ -210,7 +233,7 @@ export class LocalNode {
|
||||
newRandomSessionID(inviteAgentID)
|
||||
);
|
||||
|
||||
groupAsInvite.addMember(
|
||||
groupAsInvite.addMemberInternal(
|
||||
this.account.id,
|
||||
inviteRole === "adminInvite"
|
||||
? "admin"
|
||||
@@ -219,15 +242,16 @@ export class LocalNode {
|
||||
: "reader"
|
||||
);
|
||||
|
||||
group.groupMap.coValue._sessions = groupAsInvite.groupMap.coValue.sessions;
|
||||
group.groupMap.coValue._cachedContent = undefined;
|
||||
group.underlyingMap.core._sessions = groupAsInvite.underlyingMap.core.sessions;
|
||||
group.underlyingMap.core._cachedContent = undefined;
|
||||
|
||||
for (const groupListener of group.groupMap.coValue.listeners) {
|
||||
groupListener(group.groupMap.coValue.getCurrentContent());
|
||||
for (const groupListener of group.underlyingMap.core.listeners) {
|
||||
groupListener(group.underlyingMap.core.getCurrentContent());
|
||||
}
|
||||
}
|
||||
|
||||
expectCoValueLoaded(id: RawCoID, expectation?: string): CoValue {
|
||||
/** @internal */
|
||||
expectCoValueLoaded(id: RawCoID, expectation?: string): CoValueCore {
|
||||
const entry = this.coValues[id];
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
@@ -244,6 +268,7 @@ export class LocalNode {
|
||||
return entry.coValue;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
expectProfileLoaded(id: AccountID, expectation?: string): Profile {
|
||||
const account = this.expectCoValueLoaded(id, expectation);
|
||||
const profileID = expectGroupContent(account.getCurrentContent()).get(
|
||||
@@ -262,6 +287,7 @@ export class LocalNode {
|
||||
).getCurrentContent() as Profile;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
createAccount(
|
||||
name: string,
|
||||
agentSecret = newRandomAgentSecret()
|
||||
@@ -278,7 +304,7 @@ export class LocalNode {
|
||||
account.node
|
||||
);
|
||||
|
||||
accountAsGroup.groupMap.edit((editable) => {
|
||||
accountAsGroup.underlyingMap.edit((editable) => {
|
||||
editable.set(getAgentID(agentSecret), "admin", "trusting");
|
||||
|
||||
const readKey = newRandomKeySecret();
|
||||
@@ -314,19 +340,20 @@ export class LocalNode {
|
||||
editable.set("name", name, "trusting");
|
||||
});
|
||||
|
||||
accountAsGroup.groupMap.edit((editable) => {
|
||||
accountAsGroup.underlyingMap.edit((editable) => {
|
||||
editable.set("profile", profile.id, "trusting");
|
||||
});
|
||||
|
||||
const accountOnThisNode = this.expectCoValueLoaded(account.id);
|
||||
|
||||
accountOnThisNode._sessions = {...accountAsGroup.groupMap.coValue.sessions};
|
||||
accountOnThisNode._sessions = {...accountAsGroup.underlyingMap.core.sessions};
|
||||
accountOnThisNode._cachedContent = undefined;
|
||||
|
||||
return controlledAccount;
|
||||
}
|
||||
|
||||
resolveAccountAgent(id: AccountIDOrAgentID, expectation?: string): AgentID {
|
||||
/** @internal */
|
||||
resolveAccountAgent(id: AccountID | AgentID, expectation?: string): AgentID {
|
||||
if (isAgentID(id)) {
|
||||
return id;
|
||||
}
|
||||
@@ -353,6 +380,7 @@ export class LocalNode {
|
||||
).getCurrentAgentID();
|
||||
}
|
||||
|
||||
/** Creates a new group (with the current account as the group's first admin). */
|
||||
createGroup(): Group {
|
||||
const groupCoValue = this.createCoValue({
|
||||
type: "comap",
|
||||
@@ -388,11 +416,12 @@ export class LocalNode {
|
||||
return new Group(groupContent, this);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
ownSessionID: SessionID
|
||||
currentSessionID: SessionID
|
||||
): LocalNode {
|
||||
const newNode = new LocalNode(account, ownSessionID);
|
||||
const newNode = new LocalNode(account, currentSessionID);
|
||||
|
||||
const coValuesToCopy = Object.entries(this.coValues);
|
||||
|
||||
@@ -414,7 +443,7 @@ export class LocalNode {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newCoValue = new CoValue(entry.coValue.header, newNode, {...entry.coValue.sessions});
|
||||
const newCoValue = new CoValueCore(entry.coValue.header, newNode, {...entry.coValue.sessions});
|
||||
|
||||
newNode.coValues[coValueID as RawCoID] = {
|
||||
state: "loaded",
|
||||
@@ -429,18 +458,20 @@ export class LocalNode {
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
type CoValueState =
|
||||
| {
|
||||
state: "loading";
|
||||
done: Promise<CoValue>;
|
||||
resolve: (coValue: CoValue) => void;
|
||||
done: Promise<CoValueCore>;
|
||||
resolve: (coValue: CoValueCore) => void;
|
||||
}
|
||||
| { state: "loaded"; coValue: CoValue };
|
||||
| { state: "loaded"; coValue: CoValueCore };
|
||||
|
||||
/** @internal */
|
||||
export function newLoadingState(): CoValueState {
|
||||
let resolve: (coValue: CoValue) => void;
|
||||
let resolve: (coValue: CoValueCore) => void;
|
||||
|
||||
const promise = new Promise<CoValue>((r) => {
|
||||
const promise = new Promise<CoValueCore>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { newRandomSessionID } from "./coValue.js";
|
||||
import { expectMap } from "./contentType.js";
|
||||
import { newRandomSessionID } from "./coValueCore.js";
|
||||
import { expectMap } from "./coValue.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import {
|
||||
createdNowUnique,
|
||||
@@ -63,7 +63,7 @@ test("Added adming can add a third admin to a group (high level)", () => {
|
||||
|
||||
groupAsOtherAdmin.addMember(thirdAdmin.id, "admin");
|
||||
|
||||
expect(groupAsOtherAdmin.groupMap.get(thirdAdmin.id)).toEqual("admin");
|
||||
expect(groupAsOtherAdmin.underlyingMap.get(thirdAdmin.id)).toEqual("admin");
|
||||
});
|
||||
|
||||
test("Admins can't demote other admins in a group", () => {
|
||||
@@ -108,11 +108,11 @@ test("Admins can't demote other admins in a group (high level)", () => {
|
||||
newRandomSessionID(otherAdmin.id)
|
||||
);
|
||||
|
||||
expect(() => groupAsOtherAdmin.addMember(admin.id, "writer")).toThrow(
|
||||
expect(() => groupAsOtherAdmin.addMemberInternal(admin.id, "writer")).toThrow(
|
||||
"Failed to set role"
|
||||
);
|
||||
|
||||
expect(groupAsOtherAdmin.groupMap.get(admin.id)).toEqual("admin");
|
||||
expect(groupAsOtherAdmin.underlyingMap.get(admin.id)).toEqual("admin");
|
||||
});
|
||||
|
||||
test("Admins an add writers to a group, who can't add admins, writers, or readers", () => {
|
||||
@@ -164,14 +164,14 @@ test("Admins an add writers to a group, who can't add admins, writers, or reader
|
||||
const writer = node.createAccount("writer");
|
||||
|
||||
group.addMember(writer.id, "writer");
|
||||
expect(group.groupMap.get(writer.id)).toEqual("writer");
|
||||
expect(group.underlyingMap.get(writer.id)).toEqual("writer");
|
||||
|
||||
const groupAsWriter = group.testWithDifferentAccount(
|
||||
writer,
|
||||
newRandomSessionID(writer.id)
|
||||
);
|
||||
|
||||
expect(groupAsWriter.groupMap.get(writer.id)).toEqual("writer");
|
||||
expect(groupAsWriter.underlyingMap.get(writer.id)).toEqual("writer");
|
||||
|
||||
const otherAgent = node.createAccount("otherAgent");
|
||||
|
||||
@@ -185,7 +185,7 @@ test("Admins an add writers to a group, who can't add admins, writers, or reader
|
||||
"Failed to set role"
|
||||
);
|
||||
|
||||
expect(groupAsWriter.groupMap.get(otherAgent.id)).toBeUndefined();
|
||||
expect(groupAsWriter.underlyingMap.get(otherAgent.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Admins can add readers to a group, who can't add admins, writers, or readers", () => {
|
||||
@@ -237,14 +237,14 @@ test("Admins can add readers to a group, who can't add admins, writers, or reade
|
||||
const reader = node.createAccount("reader");
|
||||
|
||||
group.addMember(reader.id, "reader");
|
||||
expect(group.groupMap.get(reader.id)).toEqual("reader");
|
||||
expect(group.underlyingMap.get(reader.id)).toEqual("reader");
|
||||
|
||||
const groupAsReader = group.testWithDifferentAccount(
|
||||
reader,
|
||||
newRandomSessionID(reader.id)
|
||||
);
|
||||
|
||||
expect(groupAsReader.groupMap.get(reader.id)).toEqual("reader");
|
||||
expect(groupAsReader.underlyingMap.get(reader.id)).toEqual("reader");
|
||||
|
||||
const otherAgent = node.createAccount("otherAgent");
|
||||
|
||||
@@ -258,7 +258,7 @@ test("Admins can add readers to a group, who can't add admins, writers, or reade
|
||||
"Failed to set role"
|
||||
);
|
||||
|
||||
expect(groupAsReader.groupMap.get(otherAgent.id)).toBeUndefined();
|
||||
expect(groupAsReader.underlyingMap.get(otherAgent.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Admins can write to an object that is owned by their group", () => {
|
||||
@@ -342,7 +342,7 @@ test("Writers can write to an object that is owned by their group (high level)",
|
||||
const childObject = group.createMap();
|
||||
|
||||
let childObjectAsWriter = expectMap(
|
||||
childObject.coValue
|
||||
childObject.core
|
||||
.testWithDifferentAccount(writer, newRandomSessionID(writer.id))
|
||||
.getCurrentContent()
|
||||
);
|
||||
@@ -401,7 +401,7 @@ test("Readers can not write to an object that is owned by their group (high leve
|
||||
const childObject = group.createMap();
|
||||
|
||||
let childObjectAsReader = expectMap(
|
||||
childObject.coValue
|
||||
childObject.core
|
||||
.testWithDifferentAccount(reader, newRandomSessionID(reader.id))
|
||||
.getCurrentContent()
|
||||
);
|
||||
@@ -553,7 +553,7 @@ test("Admins can set group read key and then writers can use it to create and re
|
||||
const childObject = group.createMap();
|
||||
|
||||
let childObjectAsWriter = expectMap(
|
||||
childObject.coValue
|
||||
childObject.core
|
||||
.testWithDifferentAccount(writer, newRandomSessionID(writer.id))
|
||||
.getCurrentContent()
|
||||
);
|
||||
@@ -647,7 +647,7 @@ test("Admins can set group read key and then use it to create private transactio
|
||||
});
|
||||
|
||||
const childContentAsReader = expectMap(
|
||||
childObject.coValue
|
||||
childObject.core
|
||||
.testWithDifferentAccount(reader, newRandomSessionID(reader.id))
|
||||
.getCurrentContent()
|
||||
);
|
||||
@@ -767,7 +767,7 @@ test("Admins can set group read key and then use it to create private transactio
|
||||
});
|
||||
|
||||
const childContentAsReader1 = expectMap(
|
||||
childObject.coValue
|
||||
childObject.core
|
||||
.testWithDifferentAccount(reader1, newRandomSessionID(reader1.id))
|
||||
.getCurrentContent()
|
||||
);
|
||||
@@ -777,7 +777,7 @@ test("Admins can set group read key and then use it to create private transactio
|
||||
group.addMember(reader2.id, "reader");
|
||||
|
||||
const childContentAsReader2 = expectMap(
|
||||
childObject.coValue
|
||||
childObject.core
|
||||
.testWithDifferentAccount(reader2, newRandomSessionID(reader2.id))
|
||||
.getCurrentContent()
|
||||
);
|
||||
@@ -863,7 +863,7 @@ test("Admins can set group read key, make a private transaction in an owned obje
|
||||
|
||||
let childObject = group.createMap();
|
||||
|
||||
const firstReadKey = childObject.coValue.getCurrentReadKey();
|
||||
const firstReadKey = childObject.core.getCurrentReadKey();
|
||||
|
||||
childObject = childObject.edit((editable) => {
|
||||
editable.set("foo", "bar", "private");
|
||||
@@ -874,7 +874,7 @@ test("Admins can set group read key, make a private transaction in an owned obje
|
||||
|
||||
group.rotateReadKey();
|
||||
|
||||
expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey);
|
||||
expect(childObject.core.getCurrentReadKey()).not.toEqual(firstReadKey);
|
||||
|
||||
childObject = childObject.edit((editable) => {
|
||||
editable.set("foo2", "bar2", "private");
|
||||
@@ -998,7 +998,7 @@ test("Admins can set group read key, make a private transaction in an owned obje
|
||||
|
||||
let childObject = group.createMap();
|
||||
|
||||
const firstReadKey = childObject.coValue.getCurrentReadKey();
|
||||
const firstReadKey = childObject.core.getCurrentReadKey();
|
||||
|
||||
childObject = childObject.edit((editable) => {
|
||||
editable.set("foo", "bar", "private");
|
||||
@@ -1009,7 +1009,7 @@ test("Admins can set group read key, make a private transaction in an owned obje
|
||||
|
||||
group.rotateReadKey();
|
||||
|
||||
expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey);
|
||||
expect(childObject.core.getCurrentReadKey()).not.toEqual(firstReadKey);
|
||||
|
||||
const reader = node.createAccount("reader");
|
||||
|
||||
@@ -1021,7 +1021,7 @@ test("Admins can set group read key, make a private transaction in an owned obje
|
||||
});
|
||||
|
||||
const childContentAsReader = expectMap(
|
||||
childObject.coValue
|
||||
childObject.core
|
||||
.testWithDifferentAccount(reader, newRandomSessionID(reader.id))
|
||||
.getCurrentContent()
|
||||
);
|
||||
@@ -1204,7 +1204,7 @@ test("Admins can set group read rey, make a private transaction in an owned obje
|
||||
|
||||
group.rotateReadKey();
|
||||
|
||||
const secondReadKey = childObject.coValue.getCurrentReadKey();
|
||||
const secondReadKey = childObject.core.getCurrentReadKey();
|
||||
|
||||
const reader = node.createAccount("reader");
|
||||
|
||||
@@ -1223,7 +1223,7 @@ test("Admins can set group read rey, make a private transaction in an owned obje
|
||||
|
||||
group.removeMember(reader.id);
|
||||
|
||||
expect(childObject.coValue.getCurrentReadKey()).not.toEqual(secondReadKey);
|
||||
expect(childObject.core.getCurrentReadKey()).not.toEqual(secondReadKey);
|
||||
|
||||
childObject = childObject.edit((editable) => {
|
||||
editable.set("foo3", "bar3", "private");
|
||||
@@ -1231,7 +1231,7 @@ test("Admins can set group read rey, make a private transaction in an owned obje
|
||||
});
|
||||
|
||||
const childContentAsReader2 = expectMap(
|
||||
childObject.coValue
|
||||
childObject.core
|
||||
.testWithDifferentAccount(reader2, newRandomSessionID(reader2.id))
|
||||
.getCurrentContent()
|
||||
);
|
||||
@@ -1242,7 +1242,7 @@ test("Admins can set group read rey, make a private transaction in an owned obje
|
||||
|
||||
expect(
|
||||
expectMap(
|
||||
childObject.coValue
|
||||
childObject.core
|
||||
.testWithDifferentAccount(reader, newRandomSessionID(reader.id))
|
||||
.getCurrentContent()
|
||||
).get("foo3")
|
||||
@@ -1373,14 +1373,14 @@ test("Admins can create an adminInvite, which can add an admin (high-level)", as
|
||||
nodeAsInvitedAdmin
|
||||
);
|
||||
|
||||
expect(groupAsInvitedAdmin.groupMap.get(invitedAdminID)).toEqual("admin");
|
||||
expect(groupAsInvitedAdmin.underlyingMap.get(invitedAdminID)).toEqual("admin");
|
||||
expect(
|
||||
groupAsInvitedAdmin.groupMap.coValue.getCurrentReadKey().secret
|
||||
groupAsInvitedAdmin.underlyingMap.core.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
|
||||
groupAsInvitedAdmin.addMember(thirdAdminID, "admin");
|
||||
groupAsInvitedAdmin.addMemberInternal(thirdAdminID, "admin");
|
||||
|
||||
expect(groupAsInvitedAdmin.groupMap.get(thirdAdminID)).toEqual("admin");
|
||||
expect(groupAsInvitedAdmin.underlyingMap.get(thirdAdminID)).toEqual("admin");
|
||||
});
|
||||
|
||||
test("Admins can create a writerInvite, which can add a writer", () => {
|
||||
@@ -1484,9 +1484,9 @@ test("Admins can create a writerInvite, which can add a writer (high-level)", as
|
||||
nodeAsInvitedWriter
|
||||
);
|
||||
|
||||
expect(groupAsInvitedWriter.groupMap.get(invitedWriterID)).toEqual("writer");
|
||||
expect(groupAsInvitedWriter.underlyingMap.get(invitedWriterID)).toEqual("writer");
|
||||
expect(
|
||||
groupAsInvitedWriter.groupMap.coValue.getCurrentReadKey().secret
|
||||
groupAsInvitedWriter.underlyingMap.core.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -1592,9 +1592,9 @@ test("Admins can create a readerInvite, which can add a reader (high-level)", as
|
||||
nodeAsInvitedReader
|
||||
);
|
||||
|
||||
expect(groupAsInvitedReader.groupMap.get(invitedReaderID)).toEqual("reader");
|
||||
expect(groupAsInvitedReader.underlyingMap.get(invitedReaderID)).toEqual("reader");
|
||||
expect(
|
||||
groupAsInvitedReader.groupMap.coValue.getCurrentReadKey().secret
|
||||
groupAsInvitedReader.underlyingMap.core.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { CoID } from "./contentType.js";
|
||||
import { MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { MapOpPayload } from "./coValues/coMap.js";
|
||||
import { JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
KeyID,
|
||||
} from "./crypto.js";
|
||||
import {
|
||||
CoValue,
|
||||
CoValueCore,
|
||||
Transaction,
|
||||
TrustingTransaction,
|
||||
accountOrAgentIDfromSessionID,
|
||||
} from "./coValue.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
} from "./coValueCore.js";
|
||||
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import {
|
||||
AccountIDOrAgentID,
|
||||
AccountID,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
|
||||
export type PermissionsDef =
|
||||
| { type: "group"; initialAdmin: AccountIDOrAgentID }
|
||||
| { type: "group"; initialAdmin: AccountID | AgentID }
|
||||
| { type: "ownedByGroup"; group: RawCoID }
|
||||
| { type: "unsafeAllowAll" };
|
||||
|
||||
@@ -31,7 +31,7 @@ export type Role =
|
||||
| "readerInvite";
|
||||
|
||||
export function determineValidTransactions(
|
||||
coValue: CoValue
|
||||
coValue: CoValueCore
|
||||
): { txID: TransactionID; tx: Transaction }[] {
|
||||
if (coValue.header.ruleset.type === "group") {
|
||||
const allTrustingTransactionsSorted = Object.entries(
|
||||
@@ -63,7 +63,7 @@ export function determineValidTransactions(
|
||||
throw new Error("Group must have initialAdmin");
|
||||
}
|
||||
|
||||
const memberState: { [agent: AccountIDOrAgentID]: Role } = {};
|
||||
const memberState: { [agent: AccountID | AgentID]: Role } = {};
|
||||
|
||||
const validTransactions: { txID: TransactionID; tx: Transaction }[] =
|
||||
[];
|
||||
@@ -77,7 +77,7 @@ export function determineValidTransactions(
|
||||
const transactor = accountOrAgentIDfromSessionID(sessionID);
|
||||
|
||||
const change = tx.changes[0] as
|
||||
| MapOpPayload<AccountIDOrAgentID, Role>
|
||||
| MapOpPayload<AccountID | AgentID, Role>
|
||||
| MapOpPayload<"readKey", JsonValue>
|
||||
| MapOpPayload<"profile", CoID<Profile>>;
|
||||
if (tx.changes.length !== 1) {
|
||||
@@ -248,7 +248,7 @@ export function isKeyForKeyField(
|
||||
|
||||
export function isKeyForAccountField(
|
||||
field: string
|
||||
): field is `${KeyID}_for_${AccountIDOrAgentID}` {
|
||||
): field is `${KeyID}_for_${AccountID | AgentID}` {
|
||||
return (
|
||||
field.startsWith("key_") &&
|
||||
(field.includes("_for_sealer") || field.includes("_for_co"))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { newRandomSessionID } from "./coValue.js";
|
||||
import { newRandomSessionID } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { Peer, PeerID, SyncMessage } from "./sync.js";
|
||||
import { expectMap } from "./contentType.js";
|
||||
import { MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { expectMap } from "./coValue.js";
|
||||
import { MapOpPayload } from "./coValues/coMap.js";
|
||||
import { Group } from "./group.js";
|
||||
import {
|
||||
ReadableStream,
|
||||
@@ -45,7 +45,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
|
||||
await writer.write({
|
||||
action: "load",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
});
|
||||
@@ -58,7 +58,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
const mapTellKnownStateMsg = await reader.read();
|
||||
expect(mapTellKnownStateMsg.value).toEqual({
|
||||
action: "known",
|
||||
...map.coValue.knownState(),
|
||||
...map.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
@@ -68,21 +68,21 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
|
||||
expect(newContentMsg.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: { type: "ownedByGroup", group: group.id },
|
||||
meta: null,
|
||||
createdAt: map.coValue.header.createdAt,
|
||||
uniqueness: map.coValue.header.uniqueness,
|
||||
createdAt: map.core.header.createdAt,
|
||||
uniqueness: map.core.header.uniqueness,
|
||||
},
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -94,7 +94,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.core.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -127,10 +127,10 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
|
||||
await writer.write({
|
||||
action: "load",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: true,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 1,
|
||||
[node.currentSessionID]: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
const mapTellKnownStateMsg = await reader.read();
|
||||
expect(mapTellKnownStateMsg.value).toEqual({
|
||||
action: "known",
|
||||
...map.coValue.knownState(),
|
||||
...map.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
@@ -152,15 +152,15 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
|
||||
expect(mapNewContentMsg.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: undefined,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 1,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -172,7 +172,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.core.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -204,10 +204,10 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
|
||||
await writer.write({
|
||||
action: "load",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: false,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 0,
|
||||
[node.currentSessionID]: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -219,7 +219,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
const mapTellKnownStateMsg = await reader.read();
|
||||
expect(mapTellKnownStateMsg.value).toEqual({
|
||||
action: "known",
|
||||
...map.coValue.knownState(),
|
||||
...map.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
@@ -229,8 +229,8 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
|
||||
expect(mapNewContentHeaderOnlyMsg.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
header: map.coValue.header,
|
||||
id: map.core.id,
|
||||
header: map.core.header,
|
||||
new: {},
|
||||
} satisfies SyncMessage);
|
||||
|
||||
@@ -242,14 +242,14 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
|
||||
expect(mapEditMsg1.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -261,7 +261,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.core.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -274,14 +274,14 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
|
||||
expect(mapEditMsg2.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 1,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -293,7 +293,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.core.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -329,10 +329,10 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
|
||||
await writer.write({
|
||||
action: "known",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: false,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 0,
|
||||
[node.currentSessionID]: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -342,7 +342,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
const mapTellKnownStateMsg = await reader.read();
|
||||
expect(mapTellKnownStateMsg.value).toEqual({
|
||||
action: "known",
|
||||
...map.coValue.knownState(),
|
||||
...map.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
@@ -352,15 +352,15 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
|
||||
expect(mapNewContentMsg.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
header: map.coValue.header,
|
||||
id: map.core.id,
|
||||
header: map.core.header,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -372,7 +372,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.core.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -400,10 +400,10 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
|
||||
await writer.write({
|
||||
action: "load",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: false,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 0,
|
||||
[node.currentSessionID]: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -415,7 +415,7 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
const mapTellKnownStateMsg = await reader.read();
|
||||
expect(mapTellKnownStateMsg.value).toEqual({
|
||||
action: "known",
|
||||
...map.coValue.knownState(),
|
||||
...map.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
@@ -425,8 +425,8 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
|
||||
expect(mapNewContentHeaderOnlyMsg.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
header: map.coValue.header,
|
||||
id: map.core.id,
|
||||
header: map.core.header,
|
||||
new: {},
|
||||
} satisfies SyncMessage);
|
||||
|
||||
@@ -444,10 +444,10 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
await writer.write({
|
||||
action: "known",
|
||||
isCorrection: true,
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: true,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 1,
|
||||
[node.currentSessionID]: 1,
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
|
||||
@@ -455,15 +455,15 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
|
||||
expect(newContentAfterWrongAssumedState.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: undefined,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 1,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -475,7 +475,7 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.core.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -535,14 +535,14 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: group.groupMap.coValue.id,
|
||||
id: group.underlyingMap.core.id,
|
||||
});
|
||||
|
||||
const mapSubscribeMsg = await reader.read();
|
||||
|
||||
expect(mapSubscribeMsg.value).toEqual({
|
||||
action: "load",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: true,
|
||||
sessions: {},
|
||||
} satisfies SyncMessage);
|
||||
@@ -558,15 +558,15 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
|
||||
expect(mapNewContentMsg.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
header: map.coValue.header,
|
||||
id: map.core.id,
|
||||
header: map.core.header,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.core.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -578,7 +578,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.core.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -607,7 +607,7 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: group.groupMap.coValue.id,
|
||||
id: group.underlyingMap.core.id,
|
||||
});
|
||||
|
||||
const map = group.createMap();
|
||||
@@ -616,7 +616,7 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
|
||||
|
||||
expect(mapSubscribeMsg.value).toEqual({
|
||||
action: "load",
|
||||
...map.coValue.knownState(),
|
||||
...map.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(adminID));
|
||||
@@ -626,8 +626,8 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
|
||||
|
||||
expect(mapContentMsg.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
header: map.coValue.header,
|
||||
id: map.core.id,
|
||||
header: map.core.header,
|
||||
new: {},
|
||||
} satisfies SyncMessage);
|
||||
});
|
||||
@@ -661,14 +661,14 @@ test("When we connect a new server peer, we try to sync all existing coValues to
|
||||
|
||||
expect(groupSubscribeMessage.value).toEqual({
|
||||
action: "load",
|
||||
...group.groupMap.coValue.knownState(),
|
||||
...group.underlyingMap.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
|
||||
const secondMessage = await reader.read();
|
||||
|
||||
expect(secondMessage.value).toEqual({
|
||||
action: "load",
|
||||
...map.coValue.knownState(),
|
||||
...map.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
});
|
||||
|
||||
@@ -694,10 +694,10 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
|
||||
|
||||
await writer.write({
|
||||
action: "load",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: true,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 1,
|
||||
[node.currentSessionID]: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -709,7 +709,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
|
||||
|
||||
expect(mapTellKnownState.value).toEqual({
|
||||
action: "known",
|
||||
...map.coValue.knownState(),
|
||||
...map.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
});
|
||||
|
||||
@@ -757,7 +757,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
const groupSubscribeMsg = await from1.read();
|
||||
expect(groupSubscribeMsg.value).toMatchObject({
|
||||
action: "load",
|
||||
id: group.groupMap.coValue.id,
|
||||
id: group.underlyingMap.core.id,
|
||||
});
|
||||
|
||||
await to2.write(adminSubscribeMessage.value!);
|
||||
@@ -771,7 +771,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
|
||||
expect(
|
||||
node2.sync.peers["test1"]!.optimisticKnownStates[
|
||||
group.groupMap.coValue.id
|
||||
group.underlyingMap.core.id
|
||||
]
|
||||
).toBeDefined();
|
||||
|
||||
@@ -792,14 +792,14 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
const mapSubscriptionMsg = await from1.read();
|
||||
expect(mapSubscriptionMsg.value).toMatchObject({
|
||||
action: "load",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
});
|
||||
|
||||
const mapNewContentMsg = await from1.read();
|
||||
expect(mapNewContentMsg.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
header: map.coValue.header,
|
||||
id: map.core.id,
|
||||
header: map.core.header,
|
||||
new: {},
|
||||
} satisfies SyncMessage);
|
||||
|
||||
@@ -808,12 +808,12 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
const mapTellKnownStateMsg = await from2.read();
|
||||
expect(mapTellKnownStateMsg.value).toEqual({
|
||||
action: "known",
|
||||
id: map.coValue.id,
|
||||
id: map.core.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
} satisfies SyncMessage);
|
||||
|
||||
expect(node2.coValues[map.coValue.id]?.state).toEqual("loading");
|
||||
expect(node2.coValues[map.core.id]?.state).toEqual("loading");
|
||||
|
||||
await to2.write(mapNewContentMsg.value!);
|
||||
|
||||
@@ -829,7 +829,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
|
||||
expect(
|
||||
expectMap(
|
||||
node2.expectCoValueLoaded(map.coValue.id).getCurrentContent()
|
||||
node2.expectCoValueLoaded(map.core.id).getCurrentContent()
|
||||
).get("hello")
|
||||
).toEqual("world");
|
||||
});
|
||||
@@ -854,11 +854,11 @@ test.skip("When loading a coValue on one node, the server node it is requested f
|
||||
node1.sync.addPeer(node2asPeer);
|
||||
node2.sync.addPeer(node1asPeer);
|
||||
|
||||
await node2.loadCoValue(map.coValue.id);
|
||||
await node2.loadCoValue(map.core.id);
|
||||
|
||||
expect(
|
||||
expectMap(
|
||||
node2.expectCoValueLoaded(map.coValue.id).getCurrentContent()
|
||||
node2.expectCoValueLoaded(map.core.id).getCurrentContent()
|
||||
).get("hello")
|
||||
).toEqual("world");
|
||||
});
|
||||
@@ -898,7 +898,7 @@ test("Can sync a coValue through a server to another client", async () => {
|
||||
client2.sync.addPeer(serverAsOtherPeer);
|
||||
server.sync.addPeer(client2AsPeer);
|
||||
|
||||
const mapOnClient2 = await client2.loadCoValue(map.coValue.id);
|
||||
const mapOnClient2 = await client2.loadCoValue(map.core.id);
|
||||
|
||||
expect(expectMap(mapOnClient2.getCurrentContent()).get("hello")).toEqual(
|
||||
"world"
|
||||
@@ -941,7 +941,7 @@ test("Can sync a coValue with private transactions through a server to another c
|
||||
client2.sync.addPeer(serverAsOtherPeer);
|
||||
server.sync.addPeer(client2AsPeer);
|
||||
|
||||
const mapOnClient2 = await client2.loadCoValue(map.coValue.id);
|
||||
const mapOnClient2 = await client2.loadCoValue(map.core.id);
|
||||
|
||||
expect(expectMap(mapOnClient2.getCurrentContent()).get("hello")).toEqual(
|
||||
"world"
|
||||
@@ -971,7 +971,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: group.groupMap.coValue.id,
|
||||
id: group.underlyingMap.core.id,
|
||||
});
|
||||
|
||||
const map = group.createMap();
|
||||
@@ -980,7 +980,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
|
||||
|
||||
expect(mapSubscribeMsg.value).toEqual({
|
||||
action: "load",
|
||||
...map.coValue.knownState(),
|
||||
...map.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
@@ -990,8 +990,8 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
|
||||
|
||||
expect(mapContentMsg.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
header: map.coValue.header,
|
||||
id: map.core.id,
|
||||
header: map.core.header,
|
||||
new: {},
|
||||
} satisfies SyncMessage);
|
||||
|
||||
@@ -1025,7 +1025,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: group.groupMap.coValue.id,
|
||||
id: group.underlyingMap.core.id,
|
||||
});
|
||||
|
||||
const map = group.createMap();
|
||||
@@ -1034,7 +1034,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
|
||||
|
||||
expect(mapSubscribeMsg.value).toEqual({
|
||||
action: "load",
|
||||
...map.coValue.knownState(),
|
||||
...map.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
@@ -1044,8 +1044,8 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
|
||||
|
||||
expect(mapContentMsg.value).toEqual({
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
header: map.coValue.header,
|
||||
id: map.core.id,
|
||||
header: map.core.header,
|
||||
new: {},
|
||||
} satisfies SyncMessage);
|
||||
|
||||
@@ -1083,9 +1083,9 @@ test("If we start loading a coValue before connecting to a peer that has it, it
|
||||
|
||||
node1.sync.addPeer(node2asPeer);
|
||||
|
||||
const mapOnNode2Promise = node2.loadCoValue(map.coValue.id);
|
||||
const mapOnNode2Promise = node2.loadCoValue(map.core.id);
|
||||
|
||||
expect(node2.coValues[map.coValue.id]?.state).toEqual("loading");
|
||||
expect(node2.coValues[map.core.id]?.state).toEqual("loading");
|
||||
|
||||
node2.sync.addPeer(node1asPeer);
|
||||
|
||||
@@ -1099,7 +1099,7 @@ test("If we start loading a coValue before connecting to a peer that has it, it
|
||||
function groupContentEx(group: Group) {
|
||||
return {
|
||||
action: "content",
|
||||
id: group.groupMap.coValue.id,
|
||||
id: group.underlyingMap.core.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1113,7 +1113,7 @@ function admContEx(adminID: AccountID) {
|
||||
function groupStateEx(group: Group) {
|
||||
return {
|
||||
action: "known",
|
||||
id: group.groupMap.coValue.id,
|
||||
id: group.underlyingMap.core.id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Signature } from "./crypto.js";
|
||||
import { CoValueHeader, Transaction } from "./coValue.js";
|
||||
import { CoValue } from "./coValue.js";
|
||||
import { CoValueHeader, Transaction } from "./coValueCore.js";
|
||||
import { CoValueCore } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { newLoadingState } from "./node.js";
|
||||
import {
|
||||
@@ -258,7 +258,6 @@ export class SyncManager {
|
||||
const readIncoming = async () => {
|
||||
try {
|
||||
for await (const msg of peerState.incoming) {
|
||||
console.log("Got msg from", peerState.id, msg);
|
||||
try {
|
||||
await this.handleSyncMessage(msg, peerState);
|
||||
} catch (e) {
|
||||
@@ -394,7 +393,7 @@ export class SyncManager {
|
||||
);
|
||||
}
|
||||
|
||||
let resolveAfterDone: ((coValue: CoValue) => void) | undefined;
|
||||
let resolveAfterDone: ((coValue: CoValueCore) => void) | undefined;
|
||||
|
||||
const peerOptimisticKnownState = peer.optimisticKnownStates[msg.id];
|
||||
|
||||
@@ -411,7 +410,7 @@ export class SyncManager {
|
||||
|
||||
peerOptimisticKnownState.header = true;
|
||||
|
||||
const coValue = new CoValue(msg.header, this.local);
|
||||
const coValue = new CoValueCore(msg.header, this.local);
|
||||
|
||||
resolveAfterDone = entry.resolve;
|
||||
|
||||
@@ -497,7 +496,7 @@ export class SyncManager {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async syncCoValue(coValue: CoValue) {
|
||||
async syncCoValue(coValue: CoValueCore) {
|
||||
for (const peer of Object.values(this.peers)) {
|
||||
const optimisticKnownState = peer.optimisticKnownStates[coValue.id];
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "./crypto.js";
|
||||
import { newRandomSessionID } from "./coValue.js";
|
||||
import { newRandomSessionID } from "./coValueCore.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { expectGroupContent } from "./group.js";
|
||||
import { AnonymousControlledAccount } from "./account.js";
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"stripInternal": true
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["./src/**/*.test.*"],
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-browser-auth-local",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.9",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser": "^0.1.5",
|
||||
"jazz-browser": "^0.1.9",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -147,7 +147,7 @@ async function signUp(
|
||||
accountSecret,
|
||||
} satisfies SessionStorageData);
|
||||
|
||||
node.ownSessionID = await getSessionFor(accountID);
|
||||
node.currentSessionID = await getSessionFor(accountID);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-browser",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.9",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.5",
|
||||
"jazz-storage-indexeddb": "^0.1.5",
|
||||
"cojson": "^0.1.9",
|
||||
"jazz-storage-indexeddb": "^0.1.9",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { InviteSecret } from "cojson";
|
||||
import {
|
||||
LocalNode,
|
||||
cojsonInternals,
|
||||
CojsonInternalTypes,
|
||||
AccountID,
|
||||
AgentID,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
Peer,
|
||||
@@ -39,7 +40,7 @@ export async function createBrowserNode({
|
||||
sessionDone = sessionHandle.done;
|
||||
return sessionHandle.session;
|
||||
},
|
||||
[await IDBStorage.asPeer({ trace: true }), firstWsPeer]
|
||||
[await IDBStorage.asPeer(), firstWsPeer]
|
||||
);
|
||||
|
||||
async function websocketReconnectLoop() {
|
||||
@@ -81,7 +82,7 @@ export interface AuthProvider {
|
||||
}
|
||||
|
||||
export type SessionProvider = (
|
||||
accountID: CojsonInternalTypes.AccountIDOrAgentID
|
||||
accountID: AccountID | AgentID
|
||||
) => Promise<SessionID>;
|
||||
|
||||
export type SessionHandle = {
|
||||
@@ -90,7 +91,7 @@ export type SessionHandle = {
|
||||
};
|
||||
|
||||
function getSessionHandleFor(
|
||||
accountID: CojsonInternalTypes.AccountIDOrAgentID
|
||||
accountID: AccountID | AgentID
|
||||
): SessionHandle {
|
||||
let done!: () => void;
|
||||
const donePromise = new Promise<void>((resolve) => {
|
||||
@@ -286,9 +287,7 @@ export function createInviteLink(
|
||||
let currentCoValue = coValue;
|
||||
|
||||
while (currentCoValue.header.ruleset.type === "ownedByGroup") {
|
||||
currentCoValue = node.expectCoValueLoaded(
|
||||
currentCoValue.header.ruleset.group
|
||||
);
|
||||
currentCoValue = currentCoValue.getGroup().groupMap.coValue;
|
||||
}
|
||||
|
||||
if (currentCoValue.header.ruleset.type !== "group") {
|
||||
@@ -305,16 +304,16 @@ export function createInviteLink(
|
||||
return `${baseURL}#invitedTo=${value.id}&${inviteSecret}`;
|
||||
}
|
||||
|
||||
export function parseInviteLink(inviteURL: string):
|
||||
export function parseInviteLink<C extends ContentType>(inviteURL: string):
|
||||
| {
|
||||
valueID: CoID<ContentType>;
|
||||
valueID: CoID<C>;
|
||||
inviteSecret: InviteSecret;
|
||||
}
|
||||
| undefined {
|
||||
const url = new URL(inviteURL);
|
||||
const valueID = url.hash
|
||||
.split("&")[0]
|
||||
?.replace(/^#invitedTo=/, "") as CoID<ContentType>;
|
||||
?.replace(/^#invitedTo=/, "") as CoID<C>;
|
||||
const inviteSecret = url.hash.split("&")[1] as InviteSecret;
|
||||
if (!valueID || !inviteSecret) {
|
||||
return undefined;
|
||||
@@ -322,15 +321,15 @@ export function parseInviteLink(inviteURL: string):
|
||||
return { valueID, inviteSecret };
|
||||
}
|
||||
|
||||
export function consumeInviteLinkFromWindowLocation(node: LocalNode): Promise<
|
||||
export function consumeInviteLinkFromWindowLocation<C extends ContentType>(node: LocalNode): Promise<
|
||||
| {
|
||||
valueID: string;
|
||||
valueID: CoID<C>;
|
||||
inviteSecret: string;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const result = parseInviteLink(window.location.href);
|
||||
const result = parseInviteLink<C>(window.location.href);
|
||||
|
||||
if (result) {
|
||||
node.acceptInvite(result.valueID, result.inviteSecret)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "jazz-react-auth-local",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.11",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser-auth-local": "^0.1.5",
|
||||
"jazz-react": "^0.1.6",
|
||||
"jazz-browser-auth-local": "^0.1.9",
|
||||
"jazz-react": "^0.1.11",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.19"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2"
|
||||
"react": "17 - 18"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.tsx",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "jazz-react",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.11",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.5",
|
||||
"jazz-browser": "^0.1.5",
|
||||
"cojson": "^0.1.9",
|
||||
"jazz-browser": "^0.1.9",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.19"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2"
|
||||
"react": "17 - 18"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.tsx",
|
||||
|
||||
@@ -49,7 +49,10 @@ export function WithJazz({
|
||||
(async () => {
|
||||
const nodeHandle = await createBrowserNode({
|
||||
auth: auth,
|
||||
syncAddress,
|
||||
syncAddress:
|
||||
syncAddress ||
|
||||
new URLSearchParams(window.location.search).get("sync") ||
|
||||
undefined,
|
||||
});
|
||||
|
||||
setNode(nodeHandle.node);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-storage-indexeddb",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.9",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.5",
|
||||
"cojson": "^0.1.9",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
150
yarn.lock
150
yarn.lock
@@ -319,6 +319,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
||||
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
||||
|
||||
"@cspotcode/source-map-support@^0.8.0":
|
||||
version "0.8.1"
|
||||
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
|
||||
integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping" "0.3.9"
|
||||
|
||||
"@esbuild/android-arm64@0.18.20":
|
||||
version "0.18.20"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622"
|
||||
@@ -719,7 +726,7 @@
|
||||
"@jridgewell/sourcemap-codec" "^1.4.10"
|
||||
"@jridgewell/trace-mapping" "^0.3.9"
|
||||
|
||||
"@jridgewell/resolve-uri@^3.1.0":
|
||||
"@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0":
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721"
|
||||
integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==
|
||||
@@ -734,6 +741,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
|
||||
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
|
||||
|
||||
"@jridgewell/trace-mapping@0.3.9":
|
||||
version "0.3.9"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9"
|
||||
integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==
|
||||
dependencies:
|
||||
"@jridgewell/resolve-uri" "^3.0.3"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.10"
|
||||
|
||||
"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.9":
|
||||
version "0.3.19"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz#f8a3249862f91be48d3127c3cfe992f79b4b8811"
|
||||
@@ -1467,6 +1482,26 @@
|
||||
resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c"
|
||||
integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==
|
||||
|
||||
"@tsconfig/node10@^1.0.7":
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2"
|
||||
integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==
|
||||
|
||||
"@tsconfig/node12@^1.0.7":
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d"
|
||||
integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==
|
||||
|
||||
"@tsconfig/node14@^1.0.0":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1"
|
||||
integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==
|
||||
|
||||
"@tsconfig/node16@^1.0.2":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9"
|
||||
integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
|
||||
|
||||
"@tufjs/canonical-json@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@tufjs/canonical-json/-/canonical-json-1.0.0.tgz#eade9fd1f537993bc1f0949f3aea276ecc4fab31"
|
||||
@@ -1925,12 +1960,12 @@ acorn-jsx@^5.3.2:
|
||||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
||||
|
||||
acorn-walk@^8.2.0:
|
||||
acorn-walk@^8.1.1, acorn-walk@^8.2.0:
|
||||
version "8.2.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
|
||||
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
|
||||
|
||||
acorn@^8.10.0, acorn@^8.9.0:
|
||||
acorn@^8.10.0, acorn@^8.4.1, acorn@^8.9.0:
|
||||
version "8.10.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5"
|
||||
integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==
|
||||
@@ -2001,6 +2036,11 @@ ansi-regex@^6.0.1:
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a"
|
||||
integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==
|
||||
|
||||
ansi-sequence-parser@^1.1.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-sequence-parser/-/ansi-sequence-parser-1.1.1.tgz#e0aa1cdcbc8f8bb0b5bca625aac41f5f056973cf"
|
||||
integrity sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==
|
||||
|
||||
ansi-styles@^3.2.1:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
|
||||
@@ -2080,6 +2120,11 @@ are-we-there-yet@^3.0.0:
|
||||
delegates "^1.0.0"
|
||||
readable-stream "^3.6.0"
|
||||
|
||||
arg@^4.1.0:
|
||||
version "4.1.3"
|
||||
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
|
||||
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
|
||||
|
||||
arg@^5.0.2:
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
|
||||
@@ -2892,6 +2937,11 @@ crc32-stream@^4.0.2:
|
||||
crc-32 "^1.2.0"
|
||||
readable-stream "^3.4.0"
|
||||
|
||||
create-require@^1.1.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
|
||||
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
|
||||
|
||||
cross-fetch@4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983"
|
||||
@@ -3105,6 +3155,11 @@ diff-sequences@^29.4.3:
|
||||
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2"
|
||||
integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==
|
||||
|
||||
diff@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
|
||||
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
|
||||
|
||||
dijkstrajs@^1.0.1:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
|
||||
@@ -5499,10 +5554,15 @@ lru-cache@^7.14.1, lru-cache@^7.4.4, lru-cache@^7.5.1, lru-cache@^7.7.1:
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.1.tgz#0a3be479df549cca0e5d693ac402ff19537a6b7a"
|
||||
integrity sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==
|
||||
|
||||
lucide-react@^0.265.0:
|
||||
version "0.265.0"
|
||||
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.265.0.tgz#251558b65aa24d069171b4e2b4846af37f5cc105"
|
||||
integrity sha512-znyvziBEUQ7CKR31GiU4viomQbJrpDLG5ac+FajwiZIavC3YbPFLkzQx3dCXT4JWJx/pB34EwmtiZ0ElGZX0PA==
|
||||
lucide-react@^0.274.0:
|
||||
version "0.274.0"
|
||||
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.274.0.tgz#d3b54dcb972b12f1292061448d61d422ef2e269d"
|
||||
integrity sha512-qiWcojRXEwDiSimMX1+arnxha+ROJzZjJaVvCC0rsG6a9pUPjZePXSq7em4ZKMp0NDm1hyzPNkM7UaWC3LU2AA==
|
||||
|
||||
lunr@^2.3.9:
|
||||
version "2.3.9"
|
||||
resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1"
|
||||
integrity sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==
|
||||
|
||||
magic-string@^0.30.1:
|
||||
version "0.30.2"
|
||||
@@ -5533,7 +5593,7 @@ make-dir@^4.0.0:
|
||||
dependencies:
|
||||
semver "^7.5.3"
|
||||
|
||||
make-error@1.x:
|
||||
make-error@1.x, make-error@^1.1.1:
|
||||
version "1.3.6"
|
||||
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
|
||||
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
|
||||
@@ -5576,6 +5636,11 @@ map-obj@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a"
|
||||
integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==
|
||||
|
||||
marked@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3"
|
||||
integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==
|
||||
|
||||
marky@^1.2.2:
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0"
|
||||
@@ -5676,7 +5741,7 @@ minimatch@^8.0.2:
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
minimatch@^9.0.0, minimatch@^9.0.1:
|
||||
minimatch@^9.0.0, minimatch@^9.0.1, minimatch@^9.0.3:
|
||||
version "9.0.3"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
|
||||
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
|
||||
@@ -7221,6 +7286,16 @@ shebang-regex@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
|
||||
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
|
||||
|
||||
shiki@^0.14.1:
|
||||
version "0.14.4"
|
||||
resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.14.4.tgz#2454969b466a5f75067d0f2fa0d7426d32881b20"
|
||||
integrity sha512-IXCRip2IQzKwxArNNq1S+On4KPML3Yyn8Zzs/xRgcgOWIr8ntIK3IKzjFPfjy/7kt9ZMjc+FItfqHRBg8b6tNQ==
|
||||
dependencies:
|
||||
ansi-sequence-parser "^1.1.0"
|
||||
jsonc-parser "^3.2.0"
|
||||
vscode-oniguruma "^1.7.0"
|
||||
vscode-textmate "^8.0.0"
|
||||
|
||||
siginfo@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30"
|
||||
@@ -7566,10 +7641,10 @@ tailwind-merge@^1.14.0:
|
||||
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-1.14.0.tgz#e677f55d864edc6794562c63f5001f45093cdb8b"
|
||||
integrity sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==
|
||||
|
||||
tailwindcss-animate@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.6.tgz#c7195037481552cc47962ea50113830360fd0c28"
|
||||
integrity sha512-4WigSGMvbl3gCCact62ZvOngA+PRqhAn7si3TQ3/ZuPuQZcIEtVap+ENSXbzWhpojKB8CpvnIsrwBu8/RnHtuw==
|
||||
tailwindcss-animate@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
|
||||
integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
|
||||
|
||||
tailwindcss@^3.3.3:
|
||||
version "3.3.3"
|
||||
@@ -7803,6 +7878,25 @@ ts-jest@^29.1.1:
|
||||
semver "^7.5.3"
|
||||
yargs-parser "^21.0.1"
|
||||
|
||||
ts-node@^10.9.1:
|
||||
version "10.9.1"
|
||||
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b"
|
||||
integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==
|
||||
dependencies:
|
||||
"@cspotcode/source-map-support" "^0.8.0"
|
||||
"@tsconfig/node10" "^1.0.7"
|
||||
"@tsconfig/node12" "^1.0.7"
|
||||
"@tsconfig/node14" "^1.0.0"
|
||||
"@tsconfig/node16" "^1.0.2"
|
||||
acorn "^8.4.1"
|
||||
acorn-walk "^8.1.1"
|
||||
arg "^4.1.0"
|
||||
create-require "^1.1.0"
|
||||
diff "^4.0.1"
|
||||
make-error "^1.1.1"
|
||||
v8-compile-cache-lib "^3.0.1"
|
||||
yn "3.1.1"
|
||||
|
||||
tsconfig-paths@^4.1.2:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz#ef78e19039133446d244beac0fd6a1632e2d107c"
|
||||
@@ -7890,6 +7984,16 @@ typedarray@^0.0.6:
|
||||
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
|
||||
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
|
||||
|
||||
typedoc@^0.25.1:
|
||||
version "0.25.1"
|
||||
resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.25.1.tgz#50de2d8fb93623fbfb59e2fa6407ff40e3d3f438"
|
||||
integrity sha512-c2ye3YUtGIadxN2O6YwPEXgrZcvhlZ6HlhWZ8jQRNzwLPn2ylhdGqdR8HbyDRyALP8J6lmSANILCkkIdNPFxqA==
|
||||
dependencies:
|
||||
lunr "^2.3.9"
|
||||
marked "^4.3.0"
|
||||
minimatch "^9.0.3"
|
||||
shiki "^0.14.1"
|
||||
|
||||
typescript@5.0.2:
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.2.tgz#891e1a90c5189d8506af64b9ef929fca99ba1ee5"
|
||||
@@ -7998,6 +8102,11 @@ uuid@^9.0.0:
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"
|
||||
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
|
||||
|
||||
v8-compile-cache-lib@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
|
||||
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
|
||||
|
||||
v8-compile-cache@2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
|
||||
@@ -8087,6 +8196,16 @@ vitest@^0.34.1:
|
||||
vite-node "0.34.1"
|
||||
why-is-node-running "^2.2.2"
|
||||
|
||||
vscode-oniguruma@^1.7.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz#439bfad8fe71abd7798338d1cd3dc53a8beea94b"
|
||||
integrity sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==
|
||||
|
||||
vscode-textmate@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-8.0.0.tgz#2c7a3b1163ef0441097e0b5d6389cd5504b59e5d"
|
||||
integrity sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==
|
||||
|
||||
wait-port@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/wait-port/-/wait-port-1.0.4.tgz#6f9474645ddbf7701ac100ab6762438edf6e5689"
|
||||
@@ -8415,6 +8534,11 @@ yauzl@^2.10.0:
|
||||
buffer-crc32 "~0.2.3"
|
||||
fd-slicer "~1.1.0"
|
||||
|
||||
yn@3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
|
||||
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
|
||||
|
||||
yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
|
||||
Reference in New Issue
Block a user