Compare commits

...

25 Commits

Author SHA1 Message Date
Anselm
4a617c8323 Publish
- jazz-example-todo@0.0.22
 - cojson@0.1.7
 - cojson-simple-sync@0.1.8
 - cojson-storage-sqlite@0.1.5
 - jazz-browser@0.1.7
 - jazz-browser-auth-local@0.1.7
 - jazz-react@0.1.8
 - jazz-react-auth-local@0.1.8
 - jazz-storage-indexeddb@0.1.7
2023-09-05 17:25:24 +01:00
Anselm
eaed275a79 Stop tracing IDBStorage 2023-09-05 17:24:12 +01:00
Anselm
01fdcaed34 Hide AgentID and other internals from public API 2023-09-05 17:22:41 +01:00
Anselm
7aeb1a789b Replace getLastEditor with whoEdited/whoInserted 2023-09-05 16:54:57 +01:00
Anselm
a00649fa29 Add map, filter, reduce to CoList 2023-09-05 16:45:51 +01:00
Anselm
764954c727 Introduce ContentType.group 2023-09-05 16:38:07 +01:00
Anselm
b0ec93eb3a Make consumeInviteLinkFromWindowLocation generic 2023-09-05 16:33:15 +01:00
Anselm
4dd226bc95 Make more stuff in LocalNode private 2023-09-05 15:56:24 +01:00
Anselm
1692340856 Document CoList 2023-09-05 13:50:58 +01:00
Anselm
fbda78f908 Add/update docs for createMap/List 2023-09-05 13:38:43 +01:00
Anselm Eickhoff
61e9f6afad Merge pull request #49 from gardencmp/anselm-gar-67
Implement CoList
2023-09-05 13:28:44 +01:00
Anselm
246bbb119d Update walkthrough 2023-09-05 13:25:13 +01:00
Anselm
80054515c9 Enable strict mode again 2023-09-05 13:10:03 +01:00
Anselm
f9486a82c3 Publish
- jazz-example-todo@0.0.21
 - cojson@0.1.6
 - cojson-simple-sync@0.1.7
 - cojson-storage-sqlite@0.1.4
 - jazz-browser@0.1.6
 - jazz-browser-auth-local@0.1.6
 - jazz-react@0.1.7
 - jazz-react-auth-local@0.1.7
 - jazz-storage-indexeddb@0.1.6
2023-09-05 13:05:03 +01:00
Anselm
d0babab822 Remove log 2023-09-05 13:04:50 +01:00
Anselm
ab34172e01 Publish
- jazz-example-todo@0.0.20
 - cojson@0.1.5
 - cojson-simple-sync@0.1.6
 - cojson-storage-sqlite@0.1.3
 - jazz-browser@0.1.5
 - jazz-browser-auth-local@0.1.5
 - jazz-react@0.1.6
 - jazz-react-auth-local@0.1.6
 - jazz-storage-indexeddb@0.1.5
2023-09-05 12:59:03 +01:00
Anselm
b779a91611 Implement CoList and improve create... API types 2023-09-05 12:58:16 +01:00
Anselm
297a8646dd Less waiting around for WS to open 2023-09-05 12:57:50 +01:00
Anselm
25eb3e097f Simplify newStreamPair 2023-09-05 12:57:19 +01:00
Anselm Eickhoff
fe1092ccf6 Merge pull request #46 from gardencmp/anselm-gar-71 2023-09-04 21:58:19 +01:00
Anselm
29abbc455c Deploy to new cluster 2023-09-04 19:22:31 +01:00
Anselm
f6864e0f93 Publish
- jazz-example-todo@0.0.19
 - cojson-simple-sync@0.1.5
 - cojson-storage-sqlite@0.1.2
2023-09-04 19:18:46 +01:00
Anselm
9440b5306c Fix upsert row id 2023-09-04 19:16:18 +01:00
Anselm
aa34f1e8a6 Fix allowing empty list/task names 2023-09-04 19:16:10 +01:00
Anselm
24ce7dbdf1 Use better ws streams 2023-09-04 19:15:51 +01:00
37 changed files with 1127 additions and 325 deletions

View File

@@ -73,5 +73,5 @@ jobs:
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
cat job-instance.nomad;
NOMAD_ADDR='http://control1-london:4646' nomad job run job-instance.nomad;
NOMAD_ADDR='http://control1v2-london:4646' nomad job run job-instance.nomad;
working-directory: ./examples/todo

172
README.md
View File

@@ -164,7 +164,7 @@ Returns the role of the current account in the group.
```typescript
addMember(
accountID: AccountIDOrAgentID,
accountID: AccountID,
role: "reader" | "writer" | "admin"
)
```
@@ -189,15 +189,22 @@ Strips the specified member of all roles (preventing future writes) and rotates
#### `Group.createMap(meta?)`
```typescript
createMap<
M extends { [key: string]: JsonValue },
Meta extends JsonObject | null = null
>(meta?: Meta): CoMap<M, Meta>
createMap<M extends CoMap<{ [key: string]: JsonValue }, JsonObject | null>>(
meta?: M["meta"]
): M
```
Creates a new `CoMap` within this group, with the specified inner content type `M` and optional static metadata.
Creates a new `CoMap` within this group, with the specified specialized `CoMap` type `M` and optional static metadata.
#### `Group.createList(meta?)`
```typescript
createList<L extends CoList<JsonValue, JsonObject | null>>(
meta?: L["meta"]
): L
```
Creates a new `CoList` within this group, with the specified specialized `CoList` type `L` and optional static metadata.
#### `Group.createList(meta?)` (coming soon)
#### `Group.createStream(meta?)` (coming soon)
#### `Group.createStatic(meta)` (coming soon)
@@ -219,6 +226,14 @@ id: CoID<CoMap<M, Meta>>
Returns the CoMap's (precisely typed) `CoID`
#### `CoMap.meta`
```typescript
meta: Meta
```
Returns the CoMap's (precisely typed) static metadata
#### `CoMap.keys()`
```typescript
@@ -233,10 +248,10 @@ get<K extends keyof M>(key: K): M[K] | undefined
Returns the current value for the given key.
#### `CoMap.getLastEditor(key)`
#### `CoMap.whoEdited(key)`
```typescript
getLastEditor<K extends keyof M>(key: K): AccountID | undefined
whoEdited<K extends keyof M>(key: K): AccountID | undefined
```
Returns the accountID of the last account to modify the value for the given key.
@@ -307,10 +322,145 @@ If `privacy` is `"private"` **(default)**, `key` is encrypted in the transaction
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: `CoList`
```typescript
class CoList<
T extends JsonValue,
Meta extends JsonObject | null = null
>
```
#### `CoList.id`
```typescript
id: CoID<CoList<T, Meta>>
```
Returns the CoList's (precisely typed) `CoID`
#### `CoList.meta`
```typescript
meta: Meta
```
Returns the CoList's (precisely typed) static metadata
### `CoList.asArray()`
```typescript
asArray(): T[]
```
Returns the current items in the CoList as an array.
### `CoList.toJSON()`
```typescript
toJSON(): T[]
```
Returns the current items in the CoList as an array. (alias of asArray)
#### `CoList.whoInserted(idx)`
```typescript
whoInserted(idx: number): AccountID | undefined
```
Returns the accountID of the account that inserted value at the given index.
#### `CoList.subscribe(listener)`
```typescript
subscribe(
listener: (coMap: CoList<T, Meta>) => void
): () => void
```
Lets you subscribe to future updates to this CoList (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 `CoList`.
#### `CoList.edit(editable => {...})`
```typescript
edit(changer: (editable: WriteableCoList<T, Meta>) => void): CoList<T, Meta>
```
Lets you apply edits to a `CoList`, inside the changer callback, which receives a `WriteableCoList`. A `WritableCoList` has all the same methods as a `CoList`, but all edits made to it with `append`, `push`, `prepend` or `delete` are reflected in it immediately - so it behaves mutably, whereas a `CoList` is always immutable (you need to use `subscribe` to receive new versions of it).
```typescript
export class WriteableCoList<
T extends JsonValue,
Meta extends JsonObject | null = null,
> extends CoList<T, Meta>
```
#### `WritableCoList.append(after, value)`
```typescript
append(
after: number,
value: T,
privacy: "private" | "trusting" = "private"
): void
```
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.
#### `WritableCoList.prepend(after, value)`
```typescript
prepend(
before: number,
value: T,
privacy: "private" | "trusting" = "private"
): void
```
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.
#### `WritableCoList.push(value)`
```typescript
push(
value: T,
privacy: "private" | "trusting" = "private"
): void
```
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.
#### `WritableCoList.delete(at)`
```typescript
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.
---
### `CoValue` ContentType: `CoStram` (not yet implemented)
### `CoValue` ContentType: `CoStream` (not yet implemented)
---
### `CoValue` ContentType: `Static` (not yet implemented)

View File

@@ -57,20 +57,19 @@ import {
// ...
type TaskContent = { done: boolean; text: string };
type Task = CoMap<TaskContent>;
type Task = CoMap<{ done: boolean; text: string }>;
type TodoListContent = {
type ListOfTasks = CoList<CoID<Task>>;
type TodoList = CoMap<{
title: string;
// other keys form a set of task IDs
[taskId: CoID<Task>]: true;
};
type TodoList = CoMap<TodoListContent>;
tasks: CoID<ListOfTasks>;
}>;
// ...
```
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.
First, we define our main data model of tasks and todo lists, using CoJSON's collaborative map and list types, `CoMap` & `CoList`.
---
@@ -105,11 +104,14 @@ export default function App() {
const createList = useCallback(
(title: string) => {
if (!title) return;
const listGroup = localNode.createGroup();
const list = listGroup.createMap<TodoListContent>();
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;
@@ -157,18 +159,19 @@ If we have no `listId` set, the user can use the displayed creation input to cre
```typescript
export function TodoListComponent({ listId }: { listId: CoID<TodoList> }) {
const list = useTelepathicState(listId);
const tasks = useTelepathicState(list?.get("tasks"));
const createTask = (text: string) => {
if (!list) return;
const task = list.coValue.getGroup().createMap<TaskContent>();
if (!tasks || !text) return;
const task = tasks.group.createMap<Task>();
task.edit((task) => {
task.set("text", text);
task.set("done", false);
});
list.edit((list) => {
list.set(task.id, true);
tasks.edit((tasks) => {
tasks.push(task.id);
});
};
@@ -195,12 +198,9 @@ export function TodoListComponent({ listId }: { listId: CoID<TodoList> }) {
</TableRow>
</TableHeader>
<TableBody>
{list &&
list
.keys()
.filter((key): key is CoID<Task> =>
key.startsWith("co_")
)
{tasks &&
tasks
.asArray()
.map((taskId) => (
<TaskRow key={taskId} taskId={taskId} />
))}
@@ -224,11 +224,11 @@ export function TodoListComponent({ listId }: { listId: CoID<TodoList> }) {
}
```
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!
Here in `<TodoListComponent>`, we use `useTelepathicData()` for the first time, in this case to load the CoValue for our `TodoList` as well as the `ListOfTasks` referenced in it. `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!
`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`.
`createTask` is similar to `createList` we saw earlier, creating a new CoMap for a new task, and then adding it as an item to our `TodoList`'s `ListOfTasks`.
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>`.
As you can see, we iterate over the items of our `ListOfTasks` and render a `<TaskRow>` for each.
Below all tasks, we render a simple input for adding a task.
@@ -254,9 +254,11 @@ function TaskRow({ taskId }: { taskId: CoID<Task> }) {
<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" />}
{task?.get("text") || (
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
)}
</span>
<NameBadge accountID={task?.getLastEditor("text")} />
<NameBadge accountID={task?.whoEdited("text")} />
</div>
</TableCell>
</TableRow>
@@ -266,7 +268,7 @@ function TaskRow({ taskId }: { taskId: CoID<Task> }) {
`<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.
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 `whoEdited(key)` on our `Task` CoMap, which returns the accountID of the last account that changed a given key in the CoMap.
---
@@ -282,15 +284,19 @@ function NameBadge({ accountID }: { accountID?: AccountID }) {
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>
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" />
)
);
}
```
@@ -307,22 +313,28 @@ function InviteButton({ list }: { list: TodoList }) {
const { toast } = useToast();
return (
list.coValue.getGroup().myRole() === "admin" && (
list.group.myRole() === "admin" && (
<Button
size="sm"
className="py-0"
disabled={!list}
variant="outline"
onClick={() => {
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({
description: "Copied invite link to clipboard!",
title: "Copied invite link to clipboard!",
description: (
<img src={qr} className="w-20 h-20" />
),
})
);
}

View File

@@ -22,6 +22,10 @@ job "example-todo$BRANCH_SUFFIX" {
weight = 100
}
constraint {
distinct_hosts = true
}
task "server" {
driver = "docker"

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-todo",
"private": true,
"version": "0.0.18",
"version": "0.0.22",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,8 +16,8 @@
"@types/qrcode": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "^0.1.5",
"jazz-react-auth-local": "^0.1.5",
"jazz-react": "^0.1.8",
"jazz-react-auth-local": "^0.1.8",
"lucide-react": "^0.265.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",

View File

@@ -6,29 +6,34 @@ import {
useJazz,
useProfile,
useTelepathicState,
createInviteLink
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,
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 TaskContent = { done: boolean; text: string };
type Task = CoMap<TaskContent>;
type Task = CoMap<{ done: boolean; text: string }>;
type TodoListContent = {
type ListOfTasks = CoList<CoID<Task>>;
type TodoList = CoMap<{
title: string;
// other keys form a set of task IDs
[taskId: CoID<Task>]: true;
};
type TodoList = CoMap<TodoListContent>;
tasks: CoID<ListOfTasks>;
}>;
export default function App() {
const [listId, setListId] = useState<CoID<TodoList>>();
@@ -38,10 +43,10 @@ export default function App() {
useEffect(() => {
const listener = async () => {
const acceptedInvitation =
await consumeInviteLinkFromWindowLocation(localNode);
await consumeInviteLinkFromWindowLocation<TodoList>(localNode);
if (acceptedInvitation) {
setListId(acceptedInvitation.valueID as CoID<TodoList>);
setListId(acceptedInvitation.valueID);
window.location.hash = acceptedInvitation.valueID;
return;
}
@@ -58,11 +63,14 @@ export default function App() {
const createList = useCallback(
(title: string) => {
if (!title) return;
const listGroup = localNode.createGroup();
const list = listGroup.createMap<TodoListContent>();
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;
@@ -96,18 +104,19 @@ export default function App() {
export function TodoListComponent({ listId }: { listId: CoID<TodoList> }) {
const list = useTelepathicState(listId);
const tasks = useTelepathicState(list?.get("tasks"));
const createTask = (text: string) => {
if (!list) return;
const task = list.coValue.getGroup().createMap<TaskContent>();
if (!tasks || !text) return;
const task = tasks.group.createMap<Task>();
task.edit((task) => {
task.set("text", text);
task.set("done", false);
});
list.edit((list) => {
list.set(task.id, true);
tasks.edit((tasks) => {
tasks.push(task.id);
});
};
@@ -134,15 +143,9 @@ export function TodoListComponent({ listId }: { listId: CoID<TodoList> }) {
</TableRow>
</TableHeader>
<TableBody>
{list &&
list
.keys()
.filter((key): key is CoID<Task> =>
key.startsWith("co_")
)
.map((taskId) => (
<TaskRow key={taskId} taskId={taskId} />
))}
{tasks?.map((taskId) => (
<TaskRow key={taskId} taskId={taskId} />
))}
<TableRow key="new">
<TableCell>
<Checkbox className="mt-1" disabled />
@@ -181,9 +184,11 @@ function TaskRow({ taskId }: { taskId: CoID<Task> }) {
<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" />}
{task?.get("text") || (
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
)}
</span>
<NameBadge accountID={task?.getLastEditor("text")} />
<NameBadge accountID={task?.whoEdited("text")} />
</div>
</TableCell>
</TableRow>
@@ -200,8 +205,8 @@ function NameBadge({ accountID }: { accountID?: AccountID }) {
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
return (
profile?.get("name") && <span
return profile?.get("name") ? (
<span
className="rounded-full py-0.5 px-2 text-xs"
style={{
color: theme == "light" ? darkColor : brightColor,
@@ -210,6 +215,8 @@ function NameBadge({ accountID }: { accountID?: AccountID }) {
>
{profile.get("name")}
</span>
) : (
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
);
}
@@ -218,7 +225,7 @@ function InviteButton({ list }: { list: TodoList }) {
const { toast } = useToast();
return (
list.coValue.getGroup().myRole() === "admin" && (
list.group.myRole() === "admin" && (
<Button
size="sm"
className="py-0"
@@ -231,11 +238,15 @@ function InviteButton({ list }: { list: TodoList }) {
setExistingInviteLink(inviteLink);
}
if (inviteLink) {
const qr = await QRCode.toDataURL(inviteLink, { errorCorrectionLevel: 'L' });
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"/>,
description: (
<img src={qr} className="w-20 h-20" />
),
})
);
}
@@ -245,4 +256,4 @@ function InviteButton({ list }: { list: TodoList }) {
</Button>
)
);
}
}

View File

@@ -4,7 +4,7 @@
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.1.4",
"version": "0.1.8",
"devDependencies": {
"@types/jest": "^29.5.3",
"@types/ws": "^8.5.5",
@@ -16,8 +16,8 @@
"typescript": "5.0.2"
},
"dependencies": {
"cojson": "^0.1.4",
"cojson-storage-sqlite": "^0.1.1",
"cojson": "^0.1.7",
"cojson-storage-sqlite": "^0.1.5",
"ws": "^8.13.0"
},
"scripts": {

View File

@@ -1,8 +1,7 @@
import { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
import { WebSocketServer, createWebSocketStream } from "ws";
import { Duplex } from "node:stream";
import { TransformStream } from "node:stream/web";
import { WebSocketServer } from "ws";
import { SQLiteStorage } from "cojson-storage-sqlite";
import { websocketReadableStream, websocketWritableStream } from "./websocketStreams.js";
const wss = new WebSocketServer({ port: 4200 });
@@ -35,28 +34,7 @@ wss.on("connection", function connection(ws, req) {
clearInterval(pinging);
});
const duplexStream = createWebSocketStream(ws, {
decodeStrings: false,
readableObjectMode: true,
writableObjectMode: true,
encoding: "utf-8",
defaultEncoding: "utf-8",
});
const { readable: incomingStrings, writable: outgoingStrings } =
Duplex.toWeb(duplexStream);
const toJSON = new TransformStream({
transform: (chunk, controller) => {
controller.enqueue(JSON.parse(chunk));
},
});
const fromJSON = new TransformStream({
transform: (chunk, controller) => {
controller.enqueue(JSON.stringify(chunk));
},
});
const clientAddress =
(req.headers["x-forwarded-for"] as string | undefined)
@@ -68,11 +46,9 @@ wss.on("connection", function connection(ws, req) {
localNode.sync.addPeer({
id: clientId,
role: "client",
incoming: incomingStrings.pipeThrough(toJSON),
outgoing: fromJSON.writable,
incoming: websocketReadableStream(ws),
outgoing: websocketWritableStream(ws),
});
void fromJSON.readable.pipeTo(outgoingStrings);
ws.on("error", (e) => console.error(`Error on connection ${clientId}:`, e));
});

View File

@@ -0,0 +1,86 @@
import { WebSocket } from "ws";
import { WritableStream, ReadableStream } from "isomorphic-streams";
export function websocketReadableStream<T>(ws: WebSocket) {
ws.binaryType = "arraybuffer";
return new ReadableStream<T>({
start(controller) {
ws.addEventListener("message", (event) => {
if (typeof event.data !== "string")
return console.warn(
"Got non-string message from client",
event.data
);
const msg = JSON.parse(event.data);
if (msg.type === "ping") {
// console.debug(
// "Got ping from",
// msg.dc,
// "latency",
// Date.now() - msg.time,
// "ms"
// );
return;
}
controller.enqueue(msg);
});
ws.addEventListener("close", () => controller.close());
ws.addEventListener("error", () =>
controller.error(new Error("The WebSocket errored!"))
);
},
cancel() {
ws.close();
},
});
}
export function websocketWritableStream<T>(ws: WebSocket) {
return new WritableStream<T>({
start(controller) {
ws.addEventListener("close", () =>
controller.error(
new Error("The WebSocket closed unexpectedly!")
)
);
ws.addEventListener("error", () =>
controller.error(new Error("The WebSocket errored!"))
);
if (ws.readyState === WebSocket.OPEN) {
return;
}
return new Promise((resolve) => ws.once("open", resolve));
},
write(chunk) {
ws.send(JSON.stringify(chunk));
// Return immediately, since the web socket gives us no easy way to tell
// when the write completes.
},
close() {
return closeWS(1000);
},
abort(reason) {
return closeWS(4000, reason && reason.message);
},
});
function closeWS(code: number, reasonString?: string) {
return new Promise<void>((resolve, reject) => {
ws.onclose = (e) => {
if (e.wasClean) {
resolve();
} else {
reject(new Error("The connection was not closed cleanly"));
}
};
ws.close(code, reasonString);
});
}
}

View File

@@ -1,13 +1,13 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.1.1",
"version": "0.1.5",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^8.5.2",
"cojson": "^0.1.4",
"cojson": "^0.1.7",
"typescript": "^5.1.6"
},
"scripts": {

View File

@@ -338,17 +338,20 @@ export class SQLiteStorage {
lastSignature: msg.new[sessionID]!.lastSignature,
};
const sessionRowID = this.db
const upsertedSession = (this.db
.prepare<[number, string, number, string]>(
`INSERT INTO sessions (coValue, sessionID, lastIdx, lastSignature) VALUES (?, ?, ?, ?)
ON CONFLICT(coValue, sessionID) DO UPDATE SET lastIdx=excluded.lastIdx, lastSignature=excluded.lastSignature`
ON CONFLICT(coValue, sessionID) DO UPDATE SET lastIdx=excluded.lastIdx, lastSignature=excluded.lastSignature
RETURNING rowID`
)
.run(
.get(
sessionUpdate.coValue,
sessionUpdate.sessionID,
sessionUpdate.lastIdx,
sessionUpdate.lastSignature
).lastInsertRowid as number;
) as {rowID: number});
const sessionRowID = upsertedSession.rowID;
for (const newTransaction of actuallyNewTransactions) {
nextIdx++;

View File

@@ -5,7 +5,7 @@
"types": "dist/index.d.ts",
"type": "module",
"license": "MIT",
"version": "0.1.4",
"version": "0.1.7",
"devDependencies": {
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.2.1",

View File

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

View File

@@ -52,7 +52,7 @@ export class Account extends Group {
}
export interface GeneralizedControlledAccount {
id: AccountIDOrAgentID;
id: AccountID | AgentID;
agentSecret: AgentSecret;
currentAgentID: () => AgentID;
@@ -135,13 +135,10 @@ export class AnonymousControlledAccount
export type AccountContent = GroupContent & { profile: CoID<Profile> };
export type AccountMeta = { type: "account" };
export type AccountID = CoID<CoMap<AccountContent, AccountMeta>>;
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_");
}

View File

@@ -28,13 +28,13 @@ test("Can create coValue with new agent credentials and add transaction to it",
};
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
node.currentSessionID,
[transaction]
);
expect(
coValue.tryAddTransactions(
node.ownSessionID,
node.currentSessionID,
[transaction],
expectedNewHash,
sign(account.currentSignerSecret(), expectedNewHash)
@@ -65,13 +65,13 @@ test("transactions with wrong signature are rejected", () => {
};
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
node.currentSessionID,
[transaction]
);
expect(
coValue.tryAddTransactions(
node.ownSessionID,
node.currentSessionID,
[transaction],
expectedNewHash,
sign(getAgentSignerSecret(wrongAgent), expectedNewHash)
@@ -101,7 +101,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
};
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
node.currentSessionID,
[
{
privacy: "trusting",
@@ -117,7 +117,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
expect(
coValue.tryAddTransactions(
node.ownSessionID,
node.currentSessionID,
[transaction],
expectedNewHash,
sign(account.currentSignerSecret(), expectedNewHash)
@@ -170,7 +170,7 @@ test("New transactions in a group correctly update owned values, including subsc
expect(map.coValue.getValidSortedTransactions().length).toBe(1);
const manuallyAdddedTxSuccess = group.groupMap.coValue.tryAddTransactions(node.ownSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
const manuallyAdddedTxSuccess = group.groupMap.coValue.tryAddTransactions(node.currentSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
expect(manuallyAdddedTxSuccess).toBe(true);

View File

@@ -30,11 +30,10 @@ import {
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 { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
import { CoList } from "./contentTypes/coList.js";
import {
AccountID,
AccountIDOrAgentID,
GeneralizedControlledAccount,
} from "./account.js";
@@ -53,11 +52,11 @@ export function idforHeader(header: CoValueHeader): RawCoID {
export function accountOrAgentIDfromSessionID(
sessionID: SessionID
): AccountIDOrAgentID {
return sessionID.split("_session")[0] as AccountIDOrAgentID;
): AccountID | AgentID {
return sessionID.split("_session")[0] as AccountID | AgentID;
}
export function newRandomSessionID(accountID: AccountIDOrAgentID): SessionID {
export function newRandomSessionID(accountID: AccountID | AgentID): SessionID {
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
}
@@ -131,11 +130,11 @@ export class CoValue {
testWithDifferentAccount(
account: GeneralizedControlledAccount,
ownSessionID: SessionID
currentSessionID: SessionID
): CoValue {
const newNode = this.node.testWithDifferentAccount(
account,
ownSessionID
currentSessionID
);
return newNode.expectCoValueLoaded(this.id);
@@ -159,7 +158,7 @@ export class CoValue {
}
nextTransactionID(): TransactionID {
const sessionID = this.node.ownSessionID;
const sessionID = this.node.currentSessionID;
return {
sessionID,
txIndex: this.sessions[sessionID]?.transactions.length || 0,
@@ -294,7 +293,7 @@ export class CoValue {
};
}
const sessionID = this.node.ownSessionID;
const sessionID = this.node.currentSessionID;
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
transaction,

View File

@@ -3,7 +3,7 @@ import { createdNowUnique } from "./crypto.js";
import { LocalNode } from "./node.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
test("Empty COJSON Map works", () => {
test("Empty CoMap works", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
@@ -24,7 +24,7 @@ test("Empty COJSON Map works", () => {
expect(content.toJSON()).toEqual({});
});
test("Can insert and delete Map entries in edit()", () => {
test("Can insert and delete CoMap entries in edit()", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
@@ -53,7 +53,7 @@ test("Can insert and delete Map entries in edit()", () => {
});
});
test("Can get map entry values at different points in time", () => {
test("Can get CoMap entry values at different points in time", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
@@ -89,7 +89,7 @@ test("Can get map entry values at different points in time", () => {
});
});
test("Can get all historic values of key", () => {
test("Can get all historic values of key in CoMap", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
@@ -141,7 +141,7 @@ test("Can get all historic values of key", () => {
});
});
test("Can get last tx ID for a key", () => {
test("Can get last tx ID for a key in CoMap", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
@@ -173,3 +173,112 @@ test("Can get last tx ID for a key", () => {
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"]);
});
})

View File

@@ -1,19 +1,288 @@
import { JsonObject, JsonValue } from '../jsonValue.js';
import { CoID } from '../contentType.js';
import { CoValue } from '../coValue.js';
import { JsonObject, JsonValue } from "../jsonValue.js";
import { CoID } from "../contentType.js";
import { CoValue, accountOrAgentIDfromSessionID } from "../coValue.js";
import { SessionID, TransactionID } from "../ids.js";
import { AccountID, Group } from "../index.js";
import { isAccountID } from "../account.js";
export class CoList<T extends JsonValue, Meta extends JsonObject | null = null> {
type OpID = TransactionID & { changeIdx: number };
type InsertionOpPayload<T extends JsonValue> =
| {
op: "pre";
value: T;
before: OpID | "end";
}
| {
op: "app";
value: T;
after: OpID | "start";
};
type DeletionOpPayload = {
op: "del";
insertion: OpID;
};
export type ListOpPayload<T extends JsonValue> =
| InsertionOpPayload<T>
| DeletionOpPayload;
type InsertionEntry<T extends JsonValue> = {
madeAt: number;
predecessors: OpID[];
successors: OpID[];
} & InsertionOpPayload<T>;
type DeletionEntry = {
madeAt: number;
deletionID: OpID;
} & DeletionOpPayload;
export class CoList<
T extends JsonValue,
Meta extends JsonObject | null = null
> {
id: CoID<CoList<T, Meta>>;
type = "colist" as const;
coValue: CoValue;
afterStart: OpID[];
beforeEnd: OpID[];
insertions: {
[sessionID: SessionID]: {
[txIdx: number]: {
[changeIdx: number]: InsertionEntry<T>;
};
};
};
deletionsByInsertion: {
[deletedSessionID: SessionID]: {
[deletedTxIdx: number]: {
[deletedChangeIdx: number]: DeletionEntry[];
};
};
};
constructor(coValue: CoValue) {
this.id = coValue.id as CoID<CoList<T, Meta>>;
this.coValue = coValue;
this.afterStart = [];
this.beforeEnd = [];
this.insertions = {};
this.deletionsByInsertion = {};
this.fillOpsFromCoValue();
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
get meta(): Meta {
return this.coValue.header.meta as Meta;
}
get group(): Group {
return this.coValue.getGroup();
}
protected fillOpsFromCoValue() {
this.insertions = {};
this.deletionsByInsertion = {};
this.afterStart = [];
this.beforeEnd = [];
for (const {
txID,
changes,
madeAt,
} of this.coValue.getValidSortedTransactions()) {
for (const [changeIdx, changeUntyped] of changes.entries()) {
const change = changeUntyped as ListOpPayload<T>;
if (change.op === "pre" || change.op === "app") {
let sessionEntry = this.insertions[txID.sessionID];
if (!sessionEntry) {
sessionEntry = {};
this.insertions[txID.sessionID] = sessionEntry;
}
let txEntry = sessionEntry[txID.txIndex];
if (!txEntry) {
txEntry = {};
sessionEntry[txID.txIndex] = txEntry;
}
txEntry[changeIdx] = {
madeAt,
predecessors: [],
successors: [],
...change,
};
if (change.op === "pre") {
if (change.before === "end") {
this.beforeEnd.push({
...txID,
changeIdx,
});
} else {
const beforeEntry =
this.insertions[change.before.sessionID]?.[
change.before.txIndex
]?.[change.before.changeIdx];
if (!beforeEntry) {
throw new Error(
"Not yet implemented: insertion before missing op " +
change.before
);
}
beforeEntry.predecessors.splice(0, 0, {
...txID,
changeIdx,
});
}
} else {
if (change.after === "start") {
this.afterStart.push({
...txID,
changeIdx,
});
} else {
const afterEntry =
this.insertions[change.after.sessionID]?.[
change.after.txIndex
]?.[change.after.changeIdx];
if (!afterEntry) {
throw new Error(
"Not yet implemented: insertion after missing op " +
change.after
);
}
afterEntry.successors.push({
...txID,
changeIdx,
});
}
}
} else if (change.op === "del") {
let sessionEntry =
this.deletionsByInsertion[change.insertion.sessionID];
if (!sessionEntry) {
sessionEntry = {};
this.deletionsByInsertion[change.insertion.sessionID] =
sessionEntry;
}
let txEntry = sessionEntry[change.insertion.txIndex];
if (!txEntry) {
txEntry = {};
sessionEntry[change.insertion.txIndex] = txEntry;
}
let changeEntry = txEntry[change.insertion.changeIdx];
if (!changeEntry) {
changeEntry = [];
txEntry[change.insertion.changeIdx] = changeEntry;
}
changeEntry.push({
madeAt,
deletionID: {
...txID,
changeIdx,
},
...change,
});
} else {
throw new Error(
"Unknown list operation " +
(change as { op: unknown }).op
);
}
}
}
}
entries(): { value: T; madeAt: number; opID: OpID }[] {
const arr: { value: T; madeAt: number; opID: OpID }[] = [];
for (const opID of this.afterStart) {
this.fillArrayFromOpID(opID, arr);
}
for (const opID of this.beforeEnd) {
this.fillArrayFromOpID(opID, arr);
}
return arr;
}
private fillArrayFromOpID(
opID: OpID,
arr: { value: T; madeAt: number; opID: OpID }[]
) {
const entry =
this.insertions[opID.sessionID]?.[opID.txIndex]?.[opID.changeIdx];
if (!entry) {
throw new Error("Missing op " + opID);
}
for (const predecessor of entry.predecessors) {
this.fillArrayFromOpID(predecessor, arr);
}
const deleted =
(this.deletionsByInsertion[opID.sessionID]?.[opID.txIndex]?.[
opID.changeIdx
]?.length || 0) > 0;
if (!deleted) {
arr.push({
value: entry.value,
madeAt: entry.madeAt,
opID,
});
}
for (const successor of entry.successors) {
this.fillArrayFromOpID(successor, arr);
}
}
whoInserted(idx: number): AccountID | undefined {
const entry = this.entries()[idx];
if (!entry) {
return undefined;
}
const accountID = accountOrAgentIDfromSessionID(entry.opID.sessionID);
if (isAccountID(accountID)) {
return accountID;
} else {
return undefined;
}
}
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
);
}
edit(
changer: (editable: WriteableCoList<T, Meta>) => void
): CoList<T, Meta> {
const editable = new WriteableCoList<T, Meta>(this.coValue);
changer(editable);
return new CoList(this.coValue);
}
subscribe(listener: (coMap: CoList<T, Meta>) => void): () => void {
@@ -22,3 +291,106 @@ export class CoList<T extends JsonValue, Meta extends JsonObject | null = null>
});
}
}
export class WriteableCoList<
T extends JsonValue,
Meta extends JsonObject | null = null
> extends CoList<T, Meta> {
append(
after: number,
value: T,
privacy: "private" | "trusting" = "private"
): void {
const entries = this.entries();
let opIDBefore;
if (entries.length > 0) {
const entryBefore = entries[after];
if (!entryBefore) {
throw new Error("Invalid index " + after);
}
opIDBefore = entryBefore.opID;
} else {
if (after !== 0) {
throw new Error("Invalid index " + after);
}
opIDBefore = "start";
}
this.coValue.makeTransaction(
[
{
op: "app",
value,
after: opIDBefore,
},
],
privacy
);
this.fillOpsFromCoValue();
}
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);
}
prepend(
before: number,
value: T,
privacy: "private" | "trusting" = "private"
): void {
const entries = this.entries();
let opIDAfter;
if (entries.length > 0) {
const entryAfter = entries[before];
if (entryAfter) {
opIDAfter = entryAfter.opID;
} else {
if (before !== entries.length) {
throw new Error("Invalid index " + before);
}
opIDAfter = "end";
}
} else {
if (before !== 0) {
throw new Error("Invalid index " + before);
}
opIDAfter = "end";
}
this.coValue.makeTransaction(
[
{
op: "pre",
value,
before: opIDAfter,
},
],
privacy
);
this.fillOpsFromCoValue();
}
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(
[
{
op: "del",
insertion: entry.opID,
},
],
privacy
);
this.fillOpsFromCoValue();
}
}

View File

@@ -3,6 +3,7 @@ import { TransactionID } from '../ids.js';
import { CoID } from '../contentType.js';
import { CoValue, accountOrAgentIDfromSessionID } from '../coValue.js';
import { AccountID, isAccountID } from '../account.js';
import { Group } from '../group.js';
type MapOp<K extends string, V extends JsonValue> = {
txID: TransactionID;
@@ -46,6 +47,14 @@ export class CoMap<
this.fillOpsFromCoValue();
}
get meta(): Meta {
return this.coValue.header.meta as Meta;
}
get group(): Group {
return this.coValue.getGroup();
}
protected fillOpsFromCoValue() {
this.ops = {};
@@ -107,7 +116,7 @@ export class CoMap<
}
}
getLastEditor<K extends MapK<M>>(key: K): AccountID | undefined {
whoEdited<K extends MapK<M>>(key: K): AccountID | undefined {
const tx = this.getLastTxID(key);
if (!tx) {
return undefined;

View File

@@ -16,20 +16,21 @@ import {
getAgentID,
} from "./crypto.js";
import { LocalNode } from "./node.js";
import { SessionID, isAgentID } from "./ids.js";
import { AgentID, SessionID, isAgentID } from "./ids.js";
import {
AccountIDOrAgentID,
AccountID,
GeneralizedControlledAccount,
Profile,
} from "./account.js";
import { Role } from "./permissions.js";
import { base58 } from "@scure/base";
import { CoList } from "./contentTypes/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 }
@@ -62,15 +63,25 @@ export class Group {
return this.groupMap.id;
}
roleOf(accountID: AccountIDOrAgentID): Role | undefined {
roleOf(accountID: AccountID): Role | undefined {
return this.roleOfInternal(accountID);
}
/** @internal */
roleOfInternal(accountID: AccountID | AgentID): Role | undefined {
return this.groupMap.get(accountID);
}
myRole(): Role | undefined {
return this.roleOf(this.node.account.id);
return this.roleOfInternal(this.node.account.id);
}
addMember(accountID: AccountIDOrAgentID, role: Role) {
addMember(accountID: AccountID, role: Role) {
this.addMemberInternal(accountID, role);
}
/** @internal */
addMemberInternal(accountID: AccountID | AgentID, role: Role) {
this.groupMap = this.groupMap.edit((map) => {
const currentReadKey = this.groupMap.coValue.getCurrentReadKey();
@@ -111,7 +122,7 @@ export class Group {
const inviteSecret = agentSecretFromSecretSeed(secretSeed);
const inviteID = getAgentID(inviteSecret);
this.addMember(inviteID, `${role}Invite` as Role);
this.addMemberInternal(inviteID, `${role}Invite` as Role);
return inviteSecretFromSecretSeed(secretSeed);
}
@@ -126,7 +137,7 @@ export class Group {
} else {
return false;
}
}) as AccountIDOrAgentID[];
}) as (AccountID | AgentID)[];
const maybeCurrentReadKey = this.groupMap.coValue.getCurrentReadKey();
@@ -178,7 +189,12 @@ export class Group {
});
}
removeMember(accountID: AccountIDOrAgentID) {
removeMember(accountID: AccountID) {
this.removeMemberInternal(accountID);
}
/** @internal */
removeMemberInternal(accountID: AccountID | AgentID) {
this.groupMap = this.groupMap.edit((map) => {
map.set(accountID, "revoked", "trusting");
});
@@ -186,10 +202,9 @@ export class Group {
this.rotateReadKey();
}
createMap<
M extends { [key: string]: JsonValue },
Meta extends JsonObject | null = null
>(meta?: Meta): CoMap<M, Meta> {
createMap<M extends CoMap<{ [key: string]: JsonValue }, JsonObject | null>>(
meta?: M["meta"]
): M {
return this.node
.createCoValue({
type: "comap",
@@ -200,9 +215,26 @@ export class Group {
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as CoMap<M, Meta>;
.getCurrentContent() as M;
}
createList<L extends CoList<JsonValue, JsonObject | null>>(
meta?: L["meta"]
): L {
return this.node
.createCoValue({
type: "colist",
ruleset: {
type: "ownedByGroup",
group: this.groupMap.id,
},
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as L;
}
/** @internal */
testWithDifferentAccount(
account: GeneralizedControlledAccount,
sessionId: SessionID
@@ -230,4 +262,4 @@ export function secretSeedFromInviteSecret(inviteSecret: InviteSecret) {
}
return base58.decode(inviteSecret.slice("inviteSecret_z".length));
}
}

View File

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

View File

@@ -25,6 +25,7 @@ import type {
AccountID,
AccountContent,
ProfileContent,
ProfileMeta,
Profile,
} from "./account.js";
import type { InviteSecret } from "./group.js";
@@ -70,6 +71,7 @@ export type {
AccountContent,
Profile,
ProfileContent,
ProfileMeta,
InviteSecret
};
@@ -84,5 +86,4 @@ export namespace CojsonInternalTypes {
export type Transaction = import("./coValue.js").Transaction;
export type Signature = import("./crypto.js").Signature;
export type RawCoID = import("./ids.js").RawCoID;
export type AccountIDOrAgentID = import("./account.js").AccountIDOrAgentID;
}

View File

@@ -23,7 +23,6 @@ import { CoID, ContentType } from "./contentType.js";
import {
Account,
AccountMeta,
AccountIDOrAgentID,
accountHeaderForInitialAgentSecret,
GeneralizedControlledAccount,
ControlledAccount,
@@ -31,23 +30,24 @@ import {
AccountID,
Profile,
AccountContent,
ProfileContent,
ProfileMeta,
AccountMap,
} from "./account.js";
import { CoMap } from "./index.js";
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(
@@ -76,7 +76,7 @@ export class LocalNode {
node: nodeWithAccount,
accountID: account.id,
accountSecret: account.agentSecret,
sessionID: nodeWithAccount.ownSessionID,
sessionID: nodeWithAccount.currentSessionID,
};
}
@@ -110,6 +110,7 @@ export class LocalNode {
return node;
}
/** @internal */
createCoValue(header: CoValueHeader): CoValue {
const coValue = new CoValue(header, this);
this.coValues[coValue.id] = { state: "loaded", coValue: coValue };
@@ -119,6 +120,7 @@ export class LocalNode {
return coValue;
}
/** @internal */
loadCoValue(id: RawCoID): Promise<CoValue> {
let entry = this.coValues[id];
if (!entry) {
@@ -139,7 +141,7 @@ export class LocalNode {
}
async loadProfile(id: AccountID): Promise<Profile> {
const account = await this.load<CoMap<AccountContent>>(id);
const account = await this.load<AccountMap>(id);
const profileID = account.get("profile");
if (!profileID) {
@@ -211,7 +213,7 @@ export class LocalNode {
newRandomSessionID(inviteAgentID)
);
groupAsInvite.addMember(
groupAsInvite.addMemberInternal(
this.account.id,
inviteRole === "adminInvite"
? "admin"
@@ -228,6 +230,7 @@ export class LocalNode {
}
}
/** @internal */
expectCoValueLoaded(id: RawCoID, expectation?: string): CoValue {
const entry = this.coValues[id];
if (!entry) {
@@ -245,6 +248,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(
@@ -263,6 +267,7 @@ export class LocalNode {
).getCurrentContent() as Profile;
}
/** @internal */
createAccount(
name: string,
agentSecret = newRandomAgentSecret()
@@ -307,7 +312,7 @@ export class LocalNode {
account.node
);
const profile = accountAsGroup.createMap<ProfileContent, ProfileMeta>({
const profile = accountAsGroup.createMap<Profile>({
type: "profile",
});
@@ -327,7 +332,8 @@ export class LocalNode {
return controlledAccount;
}
resolveAccountAgent(id: AccountIDOrAgentID, expectation?: string): AgentID {
/** @internal */
resolveAccountAgent(id: AccountID | AgentID, expectation?: string): AgentID {
if (isAgentID(id)) {
return id;
}
@@ -389,11 +395,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);
@@ -430,6 +437,7 @@ export class LocalNode {
}
}
/** @internal */
type CoValueState =
| {
state: "loading";
@@ -438,6 +446,7 @@ type CoValueState =
}
| { state: "loaded"; coValue: CoValue };
/** @internal */
export function newLoadingState(): CoValueState {
let resolve: (coValue: CoValue) => void;

View File

@@ -108,7 +108,7 @@ 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"
);
@@ -1378,7 +1378,7 @@ test("Admins can create an adminInvite, which can add an admin (high-level)", as
groupAsInvitedAdmin.groupMap.coValue.getCurrentReadKey().secret
).toBeDefined();
groupAsInvitedAdmin.addMember(thirdAdminID, "admin");
groupAsInvitedAdmin.addMemberInternal(thirdAdminID, "admin");
expect(groupAsInvitedAdmin.groupMap.get(thirdAdminID)).toEqual("admin");
});

View File

@@ -10,14 +10,14 @@ import {
TrustingTransaction,
accountOrAgentIDfromSessionID,
} from "./coValue.js";
import { RawCoID, SessionID, TransactionID } from "./ids.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" };
@@ -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"))

View File

@@ -1,12 +1,17 @@
import { ReadableStream, TransformStream, WritableStream } from "isomorphic-streams";
import {
ReadableStream,
TransformStream,
WritableStream,
} from "isomorphic-streams";
import { Peer, PeerID, SyncMessage } from "./sync.js";
export function connectedPeers(
peer1id: PeerID,
peer2id: PeerID,
{
trace = false, peer1role = "peer", peer2role = "peer",
trace = false,
peer1role = "peer",
peer2role = "peer",
}: {
trace?: boolean;
peer1role?: Peer["role"];
@@ -24,9 +29,13 @@ export function connectedPeers(
new TransformStream({
transform(
chunk: SyncMessage,
controller: { enqueue: (msg: SyncMessage) => void; }
controller: { enqueue: (msg: SyncMessage) => void }
) {
trace && console.debug(`${peer2id} -> ${peer1id}`, JSON.stringify(chunk, null, 2));
trace &&
console.debug(
`${peer2id} -> ${peer1id}`,
JSON.stringify(chunk, null, 2)
);
controller.enqueue(chunk);
},
})
@@ -38,9 +47,13 @@ export function connectedPeers(
new TransformStream({
transform(
chunk: SyncMessage,
controller: { enqueue: (msg: SyncMessage) => void; }
controller: { enqueue: (msg: SyncMessage) => void }
) {
trace && console.debug(`${peer1id} -> ${peer2id}`, JSON.stringify(chunk, null, 2));
trace &&
console.debug(
`${peer1id} -> ${peer2id}`,
JSON.stringify(chunk, null, 2)
);
controller.enqueue(chunk);
},
})
@@ -65,39 +78,22 @@ export function connectedPeers(
}
export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
const queue: T[] = [];
let resolveNextItemReady: () => void = () => { };
let nextItemReady: Promise<void> = new Promise((resolve) => {
resolveNextItemReady = resolve;
});
let writerClosed = false;
let readerClosed = false;
let resolveEnqueue: (enqueue: (item: T) => void) => void;
const enqueuePromise = new Promise<(item: T) => void>((resolve) => {
resolveEnqueue = resolve;
});
let resolveClose: (close: () => void) => void;
const closePromise = new Promise<() => void>((resolve) => {
resolveClose = resolve;
});
const readable = new ReadableStream<T>({
async pull(controller) {
let retriesLeft = 3;
while (retriesLeft > 0) {
if (writerClosed) {
controller.close();
return;
}
retriesLeft--;
if (queue.length > 0) {
controller.enqueue(queue.shift()!);
if (queue.length === 0) {
nextItemReady = new Promise((resolve) => {
resolveNextItemReady = resolve;
});
}
return;
} else {
await nextItemReady;
}
}
throw new Error(
"Should only use one retry to get next item in queue."
);
async start(controller) {
resolveEnqueue(controller.enqueue.bind(controller));
resolveClose(controller.close.bind(controller));
},
cancel(_reason) {
@@ -107,22 +103,21 @@ export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
});
const writable = new WritableStream<T>({
write(chunk) {
async write(chunk) {
const enqueue = await enqueuePromise;
if (readerClosed) {
console.log("Reader closed, not writing chunk", chunk);
throw new Error("Reader closed, not writing chunk");
}
queue.push(chunk);
if (queue.length === 1) {
// make sure that await write resolves before corresponding read
setTimeout(() => resolveNextItemReady());
throw new Error("Reader closed");
} else {
// make sure write resolves before corresponding read
setTimeout(() => {
enqueue(chunk);
})
}
},
abort(_reason) {
console.log("Manually closing writer");
writerClosed = true;
resolveNextItemReady();
return Promise.resolve();
async abort(reason) {
console.debug("Manually closing writer", reason);
const close = await closePromise;
close();
},
});

View File

@@ -77,12 +77,12 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
uniqueness: map.coValue.header.uniqueness,
},
new: {
[node.ownSessionID]: {
[node.currentSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
madeAt: map.coValue.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.coValue.sessions[node.currentSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -130,7 +130,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
id: map.coValue.id,
header: true,
sessions: {
[node.ownSessionID]: 1,
[node.currentSessionID]: 1,
},
});
@@ -155,12 +155,12 @@ test("Node replies with only new tx to subscribe with some known state", async (
id: map.coValue.id,
header: undefined,
new: {
[node.ownSessionID]: {
[node.currentSessionID]: {
after: 1,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
madeAt: map.coValue.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.coValue.sessions[node.currentSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -207,7 +207,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
id: map.coValue.id,
header: false,
sessions: {
[node.ownSessionID]: 0,
[node.currentSessionID]: 0,
},
});
@@ -244,12 +244,12 @@ test("After subscribing, node sends own known state and new txs to peer", async
action: "content",
id: map.coValue.id,
new: {
[node.ownSessionID]: {
[node.currentSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
madeAt: map.coValue.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.coValue.sessions[node.currentSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -276,12 +276,12 @@ test("After subscribing, node sends own known state and new txs to peer", async
action: "content",
id: map.coValue.id,
new: {
[node.ownSessionID]: {
[node.currentSessionID]: {
after: 1,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
madeAt: map.coValue.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.coValue.sessions[node.currentSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -332,7 +332,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
id: map.coValue.id,
header: false,
sessions: {
[node.ownSessionID]: 0,
[node.currentSessionID]: 0,
},
});
@@ -355,12 +355,12 @@ test("Client replies with known new content to tellKnownState from server", asyn
id: map.coValue.id,
header: map.coValue.header,
new: {
[node.ownSessionID]: {
[node.currentSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
madeAt: map.coValue.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.coValue.sessions[node.currentSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -403,7 +403,7 @@ test("No matter the optimistic known state, node respects invalid known state me
id: map.coValue.id,
header: false,
sessions: {
[node.ownSessionID]: 0,
[node.currentSessionID]: 0,
},
});
@@ -447,7 +447,7 @@ test("No matter the optimistic known state, node respects invalid known state me
id: map.coValue.id,
header: true,
sessions: {
[node.ownSessionID]: 1,
[node.currentSessionID]: 1,
},
} satisfies SyncMessage);
@@ -458,12 +458,12 @@ test("No matter the optimistic known state, node respects invalid known state me
id: map.coValue.id,
header: undefined,
new: {
[node.ownSessionID]: {
[node.currentSessionID]: {
after: 1,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
madeAt: map.coValue.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.coValue.sessions[node.currentSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -561,12 +561,12 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
id: map.coValue.id,
header: map.coValue.header,
new: {
[node.ownSessionID]: {
[node.currentSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
madeAt: map.coValue.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.coValue.sessions[node.currentSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -697,7 +697,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
id: map.coValue.id,
header: true,
sessions: {
[node.ownSessionID]: 1,
[node.currentSessionID]: 1,
},
});

View File

@@ -268,6 +268,7 @@ export class SyncManager {
);
}
}
console.log("DONE!!!");
} catch (e) {
console.error(`Error reading from peer ${peer.id}`, e);
}
@@ -280,13 +281,32 @@ export class SyncManager {
}
trySendToPeer(peer: PeerState, msg: SyncMessage) {
return peer.outgoing.write(msg).catch((e) => {
console.error(
new Error(`Error writing to peer ${peer.id}, disconnecting`, {
cause: e,
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
console.error(
new Error(
`Writing to peer ${peer.id} took >1s - this should never happen as write should resolve quickly or error`
)
);
resolve();
}, 1000);
peer.outgoing
.write(msg)
.then(() => {
clearTimeout(timeout);
resolve();
})
);
delete this.peers[peer.id];
.catch((e) => {
console.error(
new Error(
`Error writing to peer ${peer.id}, disconnecting`,
{
cause: e,
}
)
);
delete this.peers[peer.id];
});
});
}

View File

@@ -10,6 +10,7 @@
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"stripInternal": true
},
"include": ["./src/**/*"],
"exclude": ["./src/**/*.test.*"],

View File

@@ -1,11 +1,11 @@
{
"name": "jazz-browser-auth-local",
"version": "0.1.4",
"version": "0.1.7",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"jazz-browser": "^0.1.4",
"jazz-browser": "^0.1.7",
"typescript": "^5.1.6"
},
"scripts": {

View File

@@ -147,7 +147,7 @@ async function signUp(
accountSecret,
} satisfies SessionStorageData);
node.ownSessionID = await getSessionFor(accountID);
node.currentSessionID = await getSessionFor(accountID);
return node;
}

View File

@@ -1,12 +1,12 @@
{
"name": "jazz-browser",
"version": "0.1.4",
"version": "0.1.7",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.1.4",
"jazz-storage-indexeddb": "^0.1.4",
"cojson": "^0.1.7",
"jazz-storage-indexeddb": "^0.1.7",
"typescript": "^5.1.6"
},
"scripts": {

View File

@@ -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) => {
@@ -182,10 +183,12 @@ function websocketReadableStream<T>(ws: WebSocket) {
}
controller.enqueue(msg);
};
ws.addEventListener("close", () => controller.close());
ws.addEventListener("error", () =>
controller.error(new Error("The WebSocket errored!"))
);
const closeListener = () => controller.close();
ws.addEventListener("close", closeListener);
ws.addEventListener("error", () => {
controller.error(new Error("The WebSocket errored!"));
ws.removeEventListener("close", closeListener);
});
},
cancel() {
@@ -209,23 +212,37 @@ function createWebSocketPeer(syncAddress: string): Peer {
}
function websocketWritableStream<T>(ws: WebSocket) {
const initialQueue = [] as T[];
let isOpen = false;
return new WritableStream<T>({
start(controller) {
ws.addEventListener("error", () => {
controller.error(new Error("The WebSocket errored!"));
ws.addEventListener("error", (event) => {
controller.error(
new Error("The WebSocket errored!" + JSON.stringify(event))
);
});
ws.addEventListener("close", () => {
controller.error(
new Error("The server closed the connection unexpectedly!")
);
});
return new Promise((resolve) => (ws.addEventListener("open", resolve)));
ws.addEventListener("open", () => {
for (const item of initialQueue) {
ws.send(JSON.stringify(item));
}
isOpen = true;
});
},
write(chunk) {
ws.send(JSON.stringify(chunk));
// Return immediately, since the web socket gives us no easy way to tell
// when the write completes.
async write(chunk) {
if (isOpen) {
ws.send(JSON.stringify(chunk));
// Return immediately, since the web socket gives us no easy way to tell
// when the write completes.
} else {
initialQueue.push(chunk);
}
},
close() {
@@ -270,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") {
@@ -289,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;
@@ -306,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)

View File

@@ -1,12 +1,12 @@
{
"name": "jazz-react-auth-local",
"version": "0.1.5",
"version": "0.1.8",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"jazz-browser-auth-local": "^0.1.4",
"jazz-react": "^0.1.5",
"jazz-browser-auth-local": "^0.1.7",
"jazz-react": "^0.1.8",
"typescript": "^5.1.6"
},
"devDependencies": {

View File

@@ -1,12 +1,12 @@
{
"name": "jazz-react",
"version": "0.1.5",
"version": "0.1.8",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.1.4",
"jazz-browser": "^0.1.4",
"cojson": "^0.1.7",
"jazz-browser": "^0.1.7",
"typescript": "^5.1.6"
},
"devDependencies": {

View File

@@ -3,9 +3,10 @@ import {
ContentType,
CoID,
ProfileContent,
ProfileMeta,
CoMap,
AccountID,
Profile,
JsonValue,
} from "cojson";
import React, { useEffect, useState } from "react";
import { AuthProvider, createBrowserNode } from "jazz-browser";
@@ -123,10 +124,10 @@ export function useTelepathicState<T extends ContentType>(id?: CoID<T>) {
return state;
}
export function useProfile<P extends ProfileContent = ProfileContent>(
accountID?: AccountID
): (Profile & CoMap<P>) | undefined {
const [profileID, setProfileID] = useState<CoID<Profile & CoMap<P>>>();
export function useProfile<
P extends ({ [key: string]: JsonValue } & ProfileContent) = ProfileContent
>(accountID?: AccountID): CoMap<P, ProfileMeta> | undefined {
const [profileID, setProfileID] = useState<CoID<CoMap<P, ProfileMeta>>>();
const { localNode } = useJazz();

View File

@@ -1,11 +1,11 @@
{
"name": "jazz-storage-indexeddb",
"version": "0.1.4",
"version": "0.1.7",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.1.4",
"cojson": "^0.1.7",
"typescript": "^5.1.6"
},
"devDependencies": {