Merge pull request #4 from gardencmp/anselm/gar-85-get-rid-of-the-multilogcovalue-distinction

Get rid of the MultiLog/CoValue distinction
This commit is contained in:
Anselm Eickhoff
2023-08-02 16:36:52 +01:00
committed by GitHub
15 changed files with 1264 additions and 1264 deletions

View File

@@ -30,17 +30,17 @@ THIS IS WORK IN PROGRESS
- Team (`AgentID``Role`)
- CoList (`Immutable[]`, addressable positions, insertAfter semantics)
- Agent (`{signatoryID, recipientID}[]`)
- MultiStream (independent per-session streams of `Immutable`s)
- CoStream (independent per-session streams of `Immutable`s)
- Static (single addressable `Immutable`)
## Implementation Abstractions
- MultiLog
- CoValue
- Session Logs
- Transactions
- Private (encrypted) transactions
- Trusting (unencrypted) transactions
- Rulesets
- CoValue Types
- CoValue Content Types
- LocalNode
- Peers
- AgentCredentials

View File

@@ -1,197 +1,138 @@
import { test, expect } from "bun:test";
import { expect, test } from "bun:test";
import {
agentIDfromSessionID,
CoValue,
Transaction,
getAgent,
getAgentID,
newRandomAgentCredential,
newRandomSessionID,
} from "./multilog";
} from "./coValue";
import { LocalNode } from "./node";
import { sign } from "./crypto";
test("Empty COJSON Map works", () => {
test("Can create coValue with new agent credentials and add transaction to it", () => {
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const multilog = node.createMultiLog({
type: "comap",
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
const content = multilog.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 Map entries in edit()", () => {
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const multilog = node.createMultiLog({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
const content = multilog.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 map entry values at different points in time", () => {
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const multilog = node.createMultiLog({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
const content = multilog.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
const beforeA = Date.now();
Bun.sleepSync(1);
editable.set("hello", "A", "trusting");
const beforeB = Date.now();
Bun.sleepSync(1);
editable.set("hello", "B", "trusting");
const beforeC = Date.now();
Bun.sleepSync(1);
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", () => {
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const multilog = node.createMultiLog({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
const content = multilog.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([
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
txID: txA,
value: "A",
at: txA && multilog.getTx(txA)?.madeAt,
hello: "world",
},
{
txID: txB,
value: "B",
at: txB && multilog.getTx(txB)?.madeAt,
},
{
txID: txDel,
value: undefined,
at: txDel && multilog.getTx(txDel)?.madeAt,
},
{
txID: txC,
value: "C",
at: txC && multilog.getTx(txC)?.madeAt,
},
]);
});
],
};
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
[transaction]
);
expect(
coValue.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(agentCredential.signatorySecret, expectedNewHash)
)
).toBe(true);
});
test("Can get last tx ID for a key", () => {
test("transactions with wrong signature are rejected", () => {
const agent = newRandomAgentCredential();
const wrongAgent = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const multilog = node.createMultiLog({
type: "comap",
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
const content = multilog.getCurrentContent();
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
if (content.type !== "comap") {
throw new Error("Expected map");
}
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
[transaction]
);
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 && agentIDfromSessionID(sessionID)).toEqual(
getAgentID(getAgent(agentCredential))
);
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);
});
expect(
coValue.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(wrongAgent.signatorySecret, expectedNewHash)
)
).toBe(false);
});
test("transactions with correctly signed, but wrong hash are rejected", () => {
const agent = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
[
{
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "wrong",
},
],
},
]
);
expect(
coValue.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(agent.signatorySecret, expectedNewHash)
)
).toBe(false);
});

View File

@@ -1,239 +1,521 @@
import { JsonAtom, JsonObject, JsonValue } from "./jsonValue";
import { MultiLog, MultiLogID, TransactionID } from "./multilog";
import { randomBytes } from "@noble/hashes/utils";
import { CoList, CoMap, ContentType, Static, CoStream } from "./contentType";
import {
Encrypted,
Hash,
KeySecret,
RecipientID,
RecipientSecret,
SignatoryID,
SignatorySecret,
Signature,
StreamingHash,
getRecipientID,
getSignatoryID,
newRandomRecipient,
newRandomSignatory,
openAs,
shortHash,
sign,
verify,
encryptForTransaction,
decryptForTransaction,
KeyID,
unsealKeySecret,
} from "./crypto";
import { JsonValue } from "./jsonValue";
import { base58 } from "@scure/base";
import {
PermissionsDef as RulesetDef,
determineValidTransactions,
expectTeamContent,
} from "./permissions";
import { LocalNode } from "./node";
import { CoValueKnownState, NewContentMessage } from "./sync";
export type CoValueID<T extends CoValue> = MultiLogID & {
readonly __type: T;
export type RawCoValueID = `coval_${string}`;
export type CoValueHeader = {
type: ContentType["type"];
ruleset: RulesetDef;
meta: JsonValue;
};
export type CoValue =
| CoMap<{[key: string]: JsonValue}, JsonValue>
| CoList<JsonValue, JsonValue>
| MultiStream<JsonValue, JsonValue>
| Static<JsonValue>;
function coValueIDforHeader(header: CoValueHeader): RawCoValueID {
const hash = shortHash(header);
return `coval_${hash.slice("shortHash_".length)}`;
}
type MapOp<K extends string, V extends JsonValue> = {
txID: TransactionID;
export type SessionID = `session_${string}_${AgentID}`;
export function agentIDfromSessionID(sessionID: SessionID): AgentID {
return `agent_${sessionID.substring(sessionID.lastIndexOf("_") + 1)}`;
}
export function newRandomSessionID(agentID: AgentID): SessionID {
return `session_${base58.encode(randomBytes(8))}_${agentID}`;
}
type SessionLog = {
transactions: Transaction[];
lastHash?: Hash;
streamingHash: StreamingHash;
lastSignature: Signature;
};
export type PrivateTransaction = {
privacy: "private";
madeAt: number;
changeIdx: number;
} & MapOpPayload<K, V>;
keyUsed: KeyID;
encryptedChanges: Encrypted<
JsonValue[],
{ in: RawCoValueID; tx: TransactionID }
>;
};
// TODO: add after TransactionID[] for conflicts/ordering
export type MapOpPayload<K extends string, V extends JsonValue> =
| {
op: "insert";
key: K;
value: V;
}
| {
op: "delete";
key: K;
};
export type TrustingTransaction = {
privacy: "trusting";
madeAt: number;
changes: JsonValue[];
};
export class CoMap<
M extends {[key: string]: JsonValue},
Meta extends JsonValue,
K extends string = keyof M & string,
V extends JsonValue = M[K],
MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]}
> {
id: CoValueID<CoMap<MM, Meta>>;
multiLog: MultiLog;
type: "comap" = "comap";
ops: {[KK in K]?: MapOp<K, M[KK]>[]};
export type Transaction = PrivateTransaction | TrustingTransaction;
constructor(multiLog: MultiLog) {
this.id = multiLog.id as CoValueID<CoMap<MM, Meta>>;
this.multiLog = multiLog;
this.ops = {};
export type DecryptedTransaction = {
txID: TransactionID;
changes: JsonValue[];
madeAt: number;
};
this.fillOpsFromMultilog();
export type TransactionID = { sessionID: SessionID; txIndex: number };
export class CoValue {
id: RawCoValueID;
node: LocalNode;
header: CoValueHeader;
sessions: { [key: SessionID]: SessionLog };
content?: ContentType;
constructor(header: CoValueHeader, node: LocalNode) {
this.id = coValueIDforHeader(header);
this.header = header;
this.sessions = {};
this.node = node;
}
protected fillOpsFromMultilog() {
this.ops = {};
testWithDifferentCredentials(
agentCredential: AgentCredential,
ownSessionID: SessionID
): CoValue {
const newNode = this.node.testWithDifferentCredentials(
agentCredential,
ownSessionID
);
for (const { txID, changes, madeAt } of this.multiLog.getValidSortedTransactions()) {
for (const [changeIdx, changeUntyped] of (
changes
).entries()) {
const change = changeUntyped as MapOpPayload<K, V>
let entries = this.ops[change.key];
if (!entries) {
entries = [];
this.ops[change.key] = entries;
}
entries.push({
return newNode.expectCoValueLoaded(this.id);
}
knownState(): CoValueKnownState {
return {
coValueID: 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[],
newHash: Hash,
newSignature: Signature
): boolean {
const signatoryID =
this.node.knownAgents[agentIDfromSessionID(sessionID)]?.signatoryID;
if (!signatoryID) {
console.warn("Unknown agent", agentIDfromSessionID(sessionID));
return false;
}
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
sessionID,
newTransactions
);
if (newHash !== expectedNewHash) {
console.warn("Invalid hash", { newHash, expectedNewHash });
return false;
}
if (!verify(newSignature, newHash, signatoryID)) {
console.warn(
"Invalid signature",
newSignature,
newHash,
signatoryID
);
return false;
}
const transactions = this.sessions[sessionID]?.transactions ?? [];
transactions.push(...newTransactions);
this.sessions[sessionID] = {
transactions,
lastHash: newHash,
streamingHash: newStreamingHash,
lastSignature: newSignature,
};
this.content = undefined;
this.node.syncCoValue(this);
const _ = this.getCurrentContent();
return true;
}
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();
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.agentCredential.signatorySecret,
expectedNewHash
);
return this.tryAddTransactions(
sessionID,
[transaction],
expectedNewHash,
signature
);
}
getCurrentContent(): ContentType {
if (this.content) {
return this.content;
}
if (this.header.type === "comap") {
this.content = new CoMap(this);
} else if (this.header.type === "colist") {
this.content = new CoList(this);
} else if (this.header.type === "costream") {
this.content = new CoStream(this);
} else if (this.header.type === "static") {
this.content = new Static(this);
} else {
throw new Error(`Unknown coValue type ${this.header.type}`);
}
return this.content;
}
getValidSortedTransactions(): DecryptedTransaction[] {
const validTransactions = determineValidTransactions(this);
const allTransactions: DecryptedTransaction[] = validTransactions.map(
({ txID, tx }) => {
return {
txID,
madeAt,
changeIdx,
...(change as any),
});
madeAt: tx.madeAt,
changes:
tx.privacy === "private"
? decryptForTransaction(
tx.encryptedChanges,
this.getReadKey(tx.keyUsed),
{
in: this.id,
tx: txID,
}
) ||
(() => {
throw new Error("Couldn't decrypt changes");
})()
: tx.changes,
};
}
}
);
allTransactions.sort(
(a, b) =>
a.madeAt - b.madeAt ||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
a.txID.txIndex - b.txID.txIndex
);
return allTransactions;
}
keys(): K[] {
return Object.keys(this.ops) as K[];
}
getCurrentReadKey(): { secret: KeySecret; id: KeyID } {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
get<KK extends K>(key: KK): M[KK] | undefined {
const ops = this.ops[key];
if (!ops) {
return undefined;
}
const currentKeyId = content.get("readKey")?.keyID;
let lastEntry = ops[ops.length - 1];
if (!currentKeyId) {
throw new Error("No readKey set");
}
if (lastEntry.op === "delete") {
return undefined;
const secret = this.getReadKey(currentKeyId);
return {
secret: secret,
id: currentKeyId,
};
} else if (this.header.ruleset.type === "ownedByTeam") {
return this.node
.expectCoValueLoaded(this.header.ruleset.team)
.getCurrentReadKey();
} else {
return lastEntry.value;
throw new Error(
"Only teams or values owned by teams have read secrets"
);
}
}
getAtTime<KK extends K>(key: KK, time: number): M[KK] | undefined {
const ops = this.ops[key];
if (!ops) {
return undefined;
}
getReadKey(keyID: KeyID): KeySecret {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
const lastOpBeforeOrAtTime = ops.findLast((op) => op.madeAt <= time);
const readKeyHistory = content.getHistory("readKey");
if (!lastOpBeforeOrAtTime) {
return undefined;
}
// Try to find direct relevation of key for us
if (lastOpBeforeOrAtTime.op === "delete") {
return undefined;
for (const entry of readKeyHistory) {
if (entry.value?.keyID === keyID) {
const revealer = agentIDfromSessionID(entry.txID.sessionID);
const revealerAgent = this.node.knownAgents[revealer];
if (!revealerAgent) {
throw new Error("Unknown revealer");
}
const secret = openAs(
entry.value.revelation,
this.node.agentCredential.recipientSecret,
revealerAgent.recipientID,
{
in: this.id,
tx: entry.txID,
}
);
if (secret) return secret as KeySecret;
}
}
// Try to find indirect revelation through previousKeys
for (const entry of readKeyHistory) {
if (entry.value?.previousKeys?.[keyID]) {
const sealingKeyID = entry.value.keyID;
const sealingKeySecret = this.getReadKey(sealingKeyID);
if (!sealingKeySecret) {
continue;
}
const secret = unsealKeySecret(
{
sealed: keyID,
sealing: sealingKeyID,
encrypted: entry.value.previousKeys[keyID],
},
sealingKeySecret
);
if (secret) {
return secret;
} else {
console.error(
`Sealing ${sealingKeyID} key didn't unseal ${keyID}`
);
}
}
}
throw new Error(
"readKey " +
keyID +
" not revealed for " +
getAgentID(getAgent(this.node.agentCredential))
);
} else if (this.header.ruleset.type === "ownedByTeam") {
return this.node
.expectCoValueLoaded(this.header.ruleset.team)
.getReadKey(keyID);
} else {
return lastOpBeforeOrAtTime.value;
throw new Error(
"Only teams or values owned by teams have read secrets"
);
}
}
getLastTxID<KK extends K>(key: KK): TransactionID | undefined {
const ops = this.ops[key];
if (!ops) {
getTx(txID: TransactionID): Transaction | undefined {
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
}
newContentSince(knownState: CoValueKnownState | undefined): NewContentMessage | undefined {
const newContent: NewContentMessage = {
action: "newContent",
coValueID: this.id,
header: knownState?.header ? undefined : this.header,
newContent: 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,
lastHash: log.lastHash,
lastSignature: log.lastSignature,
},
];
})
.filter((x): x is Exclude<typeof x, undefined> => !!x)
),
}
if (!newContent.header && Object.keys(newContent.newContent).length === 0) {
return undefined;
}
const lastEntry = ops[ops.length - 1];
return lastEntry.txID;
}
getHistory<KK extends K>(key: KK): {at: number, txID: TransactionID, value: M[KK] | undefined}[] {
const ops = this.ops[key];
if (!ops) {
return [];
}
const history: {at: number, txID: TransactionID, value: M[KK] | undefined}[] = [];
for (const op of ops) {
if (op.op === "delete") {
history.push({at: op.madeAt, txID: op.txID, value: undefined});
} else {
history.push({at: op.madeAt, txID: op.txID, value: op.value});
}
}
return history;
}
toJSON(): JsonObject {
const json: JsonObject = {};
for (const key of this.keys()) {
const value = this.get(key);
if (value !== undefined) {
json[key] = value;
}
}
return json;
}
edit(changer: (editable: WriteableCoMap<M, Meta>) => void): CoMap<M, Meta> {
const editable = new WriteableCoMap<M, Meta>(this.multiLog);
changer(editable);
return new CoMap(this.multiLog);
return newContent;
}
}
export class WriteableCoMap<
M extends {[key: string]: JsonValue},
Meta extends JsonValue,
K extends string = keyof M & string,
V extends JsonValue = M[K],
MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]}
> extends CoMap<M, Meta, K, V, MM> {
set<KK extends K>(key: KK, value: M[KK], privacy: "private" | "trusting" = "private"): void {
this.multiLog.makeTransaction([
{
op: "insert",
key,
value,
},
], privacy);
export type AgentID = `agent_${string}`;
this.fillOpsFromMultilog();
}
export type Agent = {
signatoryID: SignatoryID;
recipientID: RecipientID;
};
delete(key: K, privacy: "private" | "trusting" = "private"): void {
this.multiLog.makeTransaction([
{
op: "delete",
key,
},
], privacy);
this.fillOpsFromMultilog();
}
export function getAgent(agentCredential: AgentCredential) {
return {
signatoryID: getSignatoryID(agentCredential.signatorySecret),
recipientID: getRecipientID(agentCredential.recipientSecret),
};
}
export class CoList<T extends JsonValue, Meta extends JsonValue> {
id: CoValueID<CoList<T, Meta>>;
type: "colist" = "colist";
constructor(multilog: MultiLog) {
this.id = multilog.id as CoValueID<CoList<T, Meta>>;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
export function getAgentCoValueHeader(agent: Agent): CoValueHeader {
return {
type: "comap",
ruleset: {
type: "agent",
initialSignatoryID: agent.signatoryID,
initialRecipientID: agent.recipientID,
},
meta: null,
};
}
export class MultiStream<T extends JsonValue, Meta extends JsonValue> {
id: CoValueID<MultiStream<T, Meta>>;
type: "multistream" = "multistream";
constructor(multilog: MultiLog) {
this.id = multilog.id as CoValueID<MultiStream<T, Meta>>;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
export function getAgentID(agent: Agent): AgentID {
return `agent_${coValueIDforHeader(getAgentCoValueHeader(agent)).slice(
"coval_".length
)}`;
}
export class Static<T extends JsonValue> {
id: CoValueID<Static<T>>;
type: "static" = "static";
constructor(multilog: MultiLog) {
this.id = multilog.id as CoValueID<Static<T>>;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
export function agentIDAsCoValueID(agentID: AgentID): RawCoValueID {
return `coval_${agentID.substring("agent_".length)}`;
}
export function expectMap(content: CoValue): CoMap<{ [key: string]: string }, {}> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
export type AgentCredential = {
signatorySecret: SignatorySecret;
recipientSecret: RecipientSecret;
};
return content as CoMap<{ [key: string]: string }, {}>;
export function newRandomAgentCredential(): AgentCredential {
const signatorySecret = newRandomSignatory();
const recipientSecret = newRandomRecipient();
return { signatorySecret, recipientSecret };
}
// type Role = "admin" | "writer" | "reader";
// type PermissionsDef = CJMap<AgentID, Role, {[agent: AgentID]: Role}>;

197
src/contentType.test.ts Normal file
View File

@@ -0,0 +1,197 @@
import { test, expect } from "bun:test";
import {
agentIDfromSessionID,
getAgent,
getAgentID,
newRandomAgentCredential,
newRandomSessionID,
} from "./coValue";
import { LocalNode } from "./node";
test("Empty COJSON Map works", () => {
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
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 Map entries in edit()", () => {
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
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 map entry values at different points in time", () => {
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
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();
Bun.sleepSync(1);
editable.set("hello", "A", "trusting");
const beforeB = Date.now();
Bun.sleepSync(1);
editable.set("hello", "B", "trusting");
const beforeC = Date.now();
Bun.sleepSync(1);
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", () => {
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
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", () => {
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
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 && agentIDfromSessionID(sessionID)).toEqual(
getAgentID(getAgent(agentCredential))
);
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);
});
});

239
src/contentType.ts Normal file
View File

@@ -0,0 +1,239 @@
import { JsonAtom, JsonObject, JsonValue } from "./jsonValue";
import { CoValue, RawCoValueID, TransactionID } from "./coValue";
export type CoValueID<T extends ContentType> = RawCoValueID & {
readonly __type: T;
};
export type ContentType =
| CoMap<{[key: string]: JsonValue}, JsonValue>
| CoList<JsonValue, JsonValue>
| CoStream<JsonValue, JsonValue>
| Static<JsonValue>;
type MapOp<K extends string, V extends JsonValue> = {
txID: TransactionID;
madeAt: number;
changeIdx: number;
} & MapOpPayload<K, V>;
// TODO: add after TransactionID[] for conflicts/ordering
export type MapOpPayload<K extends string, V extends JsonValue> =
| {
op: "insert";
key: K;
value: V;
}
| {
op: "delete";
key: K;
};
export class CoMap<
M extends {[key: string]: JsonValue},
Meta extends JsonValue,
K extends string = keyof M & string,
V extends JsonValue = M[K],
MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]}
> {
id: CoValueID<CoMap<MM, Meta>>;
coValue: CoValue;
type: "comap" = "comap";
ops: {[KK in K]?: MapOp<K, M[KK]>[]};
constructor(coValue: CoValue) {
this.id = coValue.id as CoValueID<CoMap<MM, Meta>>;
this.coValue = coValue;
this.ops = {};
this.fillOpsFromCoValue();
}
protected fillOpsFromCoValue() {
this.ops = {};
for (const { txID, changes, madeAt } of this.coValue.getValidSortedTransactions()) {
for (const [changeIdx, changeUntyped] of (
changes
).entries()) {
const change = changeUntyped as MapOpPayload<K, V>
let entries = this.ops[change.key];
if (!entries) {
entries = [];
this.ops[change.key] = entries;
}
entries.push({
txID,
madeAt,
changeIdx,
...(change as any),
});
}
}
}
keys(): K[] {
return Object.keys(this.ops) as K[];
}
get<KK extends K>(key: KK): M[KK] | undefined {
const ops = this.ops[key];
if (!ops) {
return undefined;
}
let lastEntry = ops[ops.length - 1];
if (lastEntry.op === "delete") {
return undefined;
} else {
return lastEntry.value;
}
}
getAtTime<KK extends K>(key: KK, time: number): M[KK] | undefined {
const ops = this.ops[key];
if (!ops) {
return undefined;
}
const lastOpBeforeOrAtTime = ops.findLast((op) => op.madeAt <= time);
if (!lastOpBeforeOrAtTime) {
return undefined;
}
if (lastOpBeforeOrAtTime.op === "delete") {
return undefined;
} else {
return lastOpBeforeOrAtTime.value;
}
}
getLastTxID<KK extends K>(key: KK): TransactionID | undefined {
const ops = this.ops[key];
if (!ops) {
return undefined;
}
const lastEntry = ops[ops.length - 1];
return lastEntry.txID;
}
getHistory<KK extends K>(key: KK): {at: number, txID: TransactionID, value: M[KK] | undefined}[] {
const ops = this.ops[key];
if (!ops) {
return [];
}
const history: {at: number, txID: TransactionID, value: M[KK] | undefined}[] = [];
for (const op of ops) {
if (op.op === "delete") {
history.push({at: op.madeAt, txID: op.txID, value: undefined});
} else {
history.push({at: op.madeAt, txID: op.txID, value: op.value});
}
}
return history;
}
toJSON(): JsonObject {
const json: JsonObject = {};
for (const key of this.keys()) {
const value = this.get(key);
if (value !== undefined) {
json[key] = value;
}
}
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);
}
}
export class WriteableCoMap<
M extends {[key: string]: JsonValue},
Meta extends JsonValue,
K extends string = keyof M & string,
V extends JsonValue = M[K],
MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]}
> extends CoMap<M, Meta, K, V, MM> {
set<KK extends K>(key: KK, value: M[KK], privacy: "private" | "trusting" = "private"): void {
this.coValue.makeTransaction([
{
op: "insert",
key,
value,
},
], privacy);
this.fillOpsFromCoValue();
}
delete(key: K, privacy: "private" | "trusting" = "private"): void {
this.coValue.makeTransaction([
{
op: "delete",
key,
},
], privacy);
this.fillOpsFromCoValue();
}
}
export class CoList<T extends JsonValue, Meta extends JsonValue> {
id: CoValueID<CoList<T, Meta>>;
type: "colist" = "colist";
constructor(coValue: CoValue) {
this.id = coValue.id as CoValueID<CoList<T, Meta>>;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
}
export class CoStream<T extends JsonValue, Meta extends JsonValue> {
id: CoValueID<CoStream<T, Meta>>;
type: "costream" = "costream";
constructor(coValue: CoValue) {
this.id = coValue.id as CoValueID<CoStream<T, Meta>>;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
}
export class Static<T extends JsonValue> {
id: CoValueID<Static<T>>;
type: "static" = "static";
constructor(coValue: CoValue) {
this.id = coValue.id as CoValueID<Static<T>>;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
}
export function expectMap(content: ContentType): CoMap<{ [key: string]: string }, {}> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<{ [key: string]: string }, {}>;
}

View File

@@ -5,7 +5,7 @@ import { base58, base64url } from "@scure/base";
import stableStringify from "fast-json-stable-stringify";
import { blake3 } from "@noble/hashes/blake3";
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
import { MultiLogID, SessionID, TransactionID } from "./multilog";
import { RawCoValueID, SessionID, TransactionID } from "./coValue";
export type SignatorySecret = `signatorySecret_z${string}`;
export type SignatoryID = `signatory_z${string}`;
@@ -72,7 +72,7 @@ export function seal<T extends JsonValue>(
message: T,
from: RecipientSecret,
to: Set<RecipientID>,
nOnceMaterial: { in: MultiLogID; tx: TransactionID }
nOnceMaterial: { in: RawCoValueID; tx: TransactionID }
): SealedSet<T> {
const nOnce = blake3(
textEncoder.encode(stableStringify(nOnceMaterial))
@@ -113,7 +113,7 @@ export function openAs<T extends JsonValue>(
sealedSet: SealedSet<T>,
recipient: RecipientSecret,
from: RecipientID,
nOnceMaterial: { in: MultiLogID; tx: TransactionID }
nOnceMaterial: { in: RawCoValueID; tx: TransactionID }
): T | undefined {
const nOnce = blake3(
textEncoder.encode(stableStringify(nOnceMaterial))
@@ -219,8 +219,8 @@ function encrypt<T extends JsonValue, N extends JsonValue>(
export function encryptForTransaction<T extends JsonValue>(
value: T,
keySecret: KeySecret,
nOnceMaterial: { in: MultiLogID; tx: TransactionID }
): Encrypted<T, { in: MultiLogID; tx: TransactionID }> {
nOnceMaterial: { in: RawCoValueID; tx: TransactionID }
): Encrypted<T, { in: RawCoValueID; tx: TransactionID }> {
return encrypt(value, keySecret, nOnceMaterial);
}
@@ -273,9 +273,9 @@ function decrypt<T extends JsonValue, N extends JsonValue>(
}
export function decryptForTransaction<T extends JsonValue>(
encrypted: Encrypted<T, { in: MultiLogID; tx: TransactionID }>,
encrypted: Encrypted<T, { in: RawCoValueID; tx: TransactionID }>,
keySecret: KeySecret,
nOnceMaterial: { in: MultiLogID; tx: TransactionID }
nOnceMaterial: { in: RawCoValueID; tx: TransactionID }
): T | undefined {
return decrypt(encrypted, keySecret, nOnceMaterial);
}

View File

@@ -1,14 +1,14 @@
import { CoValue } from "./coValue";
import { ContentType } from "./contentType";
import { JsonValue } from "./jsonValue";
import { MultiLog } from "./multilog";
import { CoValue } from "./coValue";
import { LocalNode } from "./node";
type Value = JsonValue | CoValue;
type Value = JsonValue | ContentType;
export {
JsonValue,
CoValue,
ContentType,
Value,
LocalNode,
MultiLog
CoValue
}

View File

@@ -1,6 +1,6 @@
import { CoValueID, CoValue } from "./coValue";
import { CoValueID, ContentType } from "./contentType";
export type JsonAtom = string | number | boolean | null;
export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID<CoValue>;
export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID<ContentType>;
export type JsonArray = JsonValue[];
export type JsonObject = { [key: string]: JsonValue; };

View File

@@ -1,138 +0,0 @@
import { expect, test } from "bun:test";
import {
MultiLog,
Transaction,
getAgent,
getAgentID,
newRandomAgentCredential,
newRandomSessionID,
} from "./multilog";
import { LocalNode } from "./node";
import { sign } from "./crypto";
test("Can create multilog with new agent credentials and add transaction to it", () => {
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const multilog = node.createMultiLog({
type: "multistream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
const { expectedNewHash } = multilog.expectedNewHashAfter(
node.ownSessionID,
[transaction]
);
expect(
multilog.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(agentCredential.signatorySecret, expectedNewHash)
)
).toBe(true);
});
test("transactions with wrong signature are rejected", () => {
const agent = newRandomAgentCredential();
const wrongAgent = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const multilog = node.createMultiLog({
type: "multistream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
const { expectedNewHash } = multilog.expectedNewHashAfter(
node.ownSessionID,
[transaction]
);
expect(
multilog.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(wrongAgent.signatorySecret, expectedNewHash)
)
).toBe(false);
});
test("transactions with correctly signed, but wrong hash are rejected", () => {
const agent = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential();
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const multilog = node.createMultiLog({
type: "multistream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
});
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
const { expectedNewHash } = multilog.expectedNewHashAfter(
node.ownSessionID,
[
{
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "wrong",
},
],
},
]
);
expect(
multilog.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(agent.signatorySecret, expectedNewHash)
)
).toBe(false);
});

View File

@@ -1,521 +0,0 @@
import { randomBytes } from "@noble/hashes/utils";
import { CoList, CoMap, CoValue, Static, MultiStream } from "./coValue";
import {
Encrypted,
Hash,
KeySecret,
RecipientID,
RecipientSecret,
SignatoryID,
SignatorySecret,
Signature,
StreamingHash,
getRecipientID,
getSignatoryID,
newRandomRecipient,
newRandomSignatory,
openAs,
shortHash,
sign,
verify,
encryptForTransaction,
decryptForTransaction,
KeyID,
unsealKeySecret,
} from "./crypto";
import { JsonValue } from "./jsonValue";
import { base58 } from "@scure/base";
import {
PermissionsDef as RulesetDef,
determineValidTransactions,
expectTeamContent,
} from "./permissions";
import { LocalNode } from "./node";
import { MultiLogKnownState, NewContentMessage } from "./sync";
export type MultiLogID = `coval_${string}`;
export type MultiLogHeader = {
type: CoValue["type"];
ruleset: RulesetDef;
meta: JsonValue;
};
function multilogIDforHeader(header: MultiLogHeader): MultiLogID {
const hash = shortHash(header);
return `coval_${hash.slice("shortHash_".length)}`;
}
export type SessionID = `session_${string}_${AgentID}`;
export function agentIDfromSessionID(sessionID: SessionID): AgentID {
return `agent_${sessionID.substring(sessionID.lastIndexOf("_") + 1)}`;
}
export function newRandomSessionID(agentID: AgentID): SessionID {
return `session_${base58.encode(randomBytes(8))}_${agentID}`;
}
type SessionLog = {
transactions: Transaction[];
lastHash?: Hash;
streamingHash: StreamingHash;
lastSignature: Signature;
};
export type PrivateTransaction = {
privacy: "private";
madeAt: number;
keyUsed: KeyID;
encryptedChanges: Encrypted<
JsonValue[],
{ in: MultiLogID; 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;
};
export type TransactionID = { sessionID: SessionID; txIndex: number };
export class MultiLog {
id: MultiLogID;
node: LocalNode;
header: MultiLogHeader;
sessions: { [key: SessionID]: SessionLog };
content?: CoValue;
constructor(header: MultiLogHeader, node: LocalNode) {
this.id = multilogIDforHeader(header);
this.header = header;
this.sessions = {};
this.node = node;
}
testWithDifferentCredentials(
agentCredential: AgentCredential,
ownSessionID: SessionID
): MultiLog {
const newNode = this.node.testWithDifferentCredentials(
agentCredential,
ownSessionID
);
return newNode.expectMultiLogLoaded(this.id);
}
knownState(): MultiLogKnownState {
return {
multilogID: 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[],
newHash: Hash,
newSignature: Signature
): boolean {
const signatoryID =
this.node.knownAgents[agentIDfromSessionID(sessionID)]?.signatoryID;
if (!signatoryID) {
console.warn("Unknown agent", agentIDfromSessionID(sessionID));
return false;
}
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
sessionID,
newTransactions
);
if (newHash !== expectedNewHash) {
console.warn("Invalid hash", { newHash, expectedNewHash });
return false;
}
if (!verify(newSignature, newHash, signatoryID)) {
console.warn(
"Invalid signature",
newSignature,
newHash,
signatoryID
);
return false;
}
const transactions = this.sessions[sessionID]?.transactions ?? [];
transactions.push(...newTransactions);
this.sessions[sessionID] = {
transactions,
lastHash: newHash,
streamingHash: newStreamingHash,
lastSignature: newSignature,
};
this.content = undefined;
this.node.syncMultiLog(this);
const _ = this.getCurrentContent();
return true;
}
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();
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.agentCredential.signatorySecret,
expectedNewHash
);
return this.tryAddTransactions(
sessionID,
[transaction],
expectedNewHash,
signature
);
}
getCurrentContent(): CoValue {
if (this.content) {
return this.content;
}
if (this.header.type === "comap") {
this.content = new CoMap(this);
} else if (this.header.type === "colist") {
this.content = new CoList(this);
} else if (this.header.type === "multistream") {
this.content = new MultiStream(this);
} else if (this.header.type === "static") {
this.content = new Static(this);
} else {
throw new Error(`Unknown multilog type ${this.header.type}`);
}
return this.content;
}
getValidSortedTransactions(): DecryptedTransaction[] {
const validTransactions = determineValidTransactions(this);
const allTransactions: DecryptedTransaction[] = validTransactions.map(
({ txID, tx }) => {
return {
txID,
madeAt: tx.madeAt,
changes:
tx.privacy === "private"
? decryptForTransaction(
tx.encryptedChanges,
this.getReadKey(tx.keyUsed),
{
in: this.id,
tx: txID,
}
) ||
(() => {
throw new Error("Couldn't decrypt changes");
})()
: tx.changes,
};
}
);
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; id: KeyID } {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
const currentKeyId = content.get("readKey")?.keyID;
if (!currentKeyId) {
throw new Error("No readKey set");
}
const secret = this.getReadKey(currentKeyId);
return {
secret: secret,
id: currentKeyId,
};
} else if (this.header.ruleset.type === "ownedByTeam") {
return this.node
.expectMultiLogLoaded(this.header.ruleset.team)
.getCurrentReadKey();
} else {
throw new Error(
"Only teams or values owned by teams have read secrets"
);
}
}
getReadKey(keyID: KeyID): KeySecret {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
const readKeyHistory = content.getHistory("readKey");
// Try to find direct relevation of key for us
for (const entry of readKeyHistory) {
if (entry.value?.keyID === keyID) {
const revealer = agentIDfromSessionID(entry.txID.sessionID);
const revealerAgent = this.node.knownAgents[revealer];
if (!revealerAgent) {
throw new Error("Unknown revealer");
}
const secret = openAs(
entry.value.revelation,
this.node.agentCredential.recipientSecret,
revealerAgent.recipientID,
{
in: this.id,
tx: entry.txID,
}
);
if (secret) return secret as KeySecret;
}
}
// Try to find indirect revelation through previousKeys
for (const entry of readKeyHistory) {
if (entry.value?.previousKeys?.[keyID]) {
const sealingKeyID = entry.value.keyID;
const sealingKeySecret = this.getReadKey(sealingKeyID);
if (!sealingKeySecret) {
continue;
}
const secret = unsealKeySecret(
{
sealed: keyID,
sealing: sealingKeyID,
encrypted: entry.value.previousKeys[keyID],
},
sealingKeySecret
);
if (secret) {
return secret;
} else {
console.error(
`Sealing ${sealingKeyID} key didn't unseal ${keyID}`
);
}
}
}
throw new Error(
"readKey " +
keyID +
" not revealed for " +
getAgentID(getAgent(this.node.agentCredential))
);
} else if (this.header.ruleset.type === "ownedByTeam") {
return this.node
.expectMultiLogLoaded(this.header.ruleset.team)
.getReadKey(keyID);
} else {
throw new Error(
"Only teams or values owned by teams have read secrets"
);
}
}
getTx(txID: TransactionID): Transaction | undefined {
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
}
newContentSince(knownState: MultiLogKnownState | undefined): NewContentMessage | undefined {
const newContent: NewContentMessage = {
action: "newContent",
multilogID: this.id,
header: knownState?.header ? undefined : this.header,
newContent: 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,
lastHash: log.lastHash,
lastSignature: log.lastSignature,
},
];
})
.filter((x): x is Exclude<typeof x, undefined> => !!x)
),
}
if (!newContent.header && Object.keys(newContent.newContent).length === 0) {
return undefined;
}
return newContent;
}
}
export type AgentID = `agent_${string}`;
export type Agent = {
signatoryID: SignatoryID;
recipientID: RecipientID;
};
export function getAgent(agentCredential: AgentCredential) {
return {
signatoryID: getSignatoryID(agentCredential.signatorySecret),
recipientID: getRecipientID(agentCredential.recipientSecret),
};
}
export function getAgentMultilogHeader(agent: Agent): MultiLogHeader {
return {
type: "comap",
ruleset: {
type: "agent",
initialSignatoryID: agent.signatoryID,
initialRecipientID: agent.recipientID,
},
meta: null,
};
}
export function getAgentID(agent: Agent): AgentID {
return `agent_${multilogIDforHeader(getAgentMultilogHeader(agent)).slice(
"coval_".length
)}`;
}
export function agentIDasMultiLogID(agentID: AgentID): MultiLogID {
return `coval_${agentID.substring("agent_".length)}`;
}
export type AgentCredential = {
signatorySecret: SignatorySecret;
recipientSecret: RecipientSecret;
};
export function newRandomAgentCredential(): AgentCredential {
const signatorySecret = newRandomSignatory();
const recipientSecret = newRandomRecipient();
return { signatorySecret, recipientSecret };
}
// type Role = "admin" | "writer" | "reader";
// type PermissionsDef = CJMap<AgentID, Role, {[agent: AgentID]: Role}>;

View File

@@ -1,19 +1,19 @@
import { CoMap } from "./coValue";
import { CoMap } from "./contentType";
import { newRandomKeySecret, seal } from "./crypto";
import {
MultiLogID,
MultiLog,
RawCoValueID,
CoValue,
AgentCredential,
AgentID,
SessionID,
Agent,
getAgent,
getAgentID,
getAgentMultilogHeader,
MultiLogHeader,
getAgentCoValueHeader,
CoValueHeader,
agentIDfromSessionID,
agentIDasMultiLogID,
} from "./multilog";
agentIDAsCoValueID,
} from "./coValue";
import { Team, expectTeamContent } from "./permissions";
import {
NewContentMessage,
@@ -31,7 +31,7 @@ import {
} from "./sync";
export class LocalNode {
multilogs: { [key: MultiLogID]: MultilogState } = {};
coValues: { [key: RawCoValueID]: CoValueState } = {};
peers: { [key: PeerID]: PeerState } = {};
agentCredential: AgentCredential;
agentID: AgentID;
@@ -46,35 +46,35 @@ export class LocalNode {
this.knownAgents[agentID] = agent;
this.ownSessionID = ownSessionID;
const agentMultilog = new MultiLog(getAgentMultilogHeader(agent), this);
this.multilogs[agentMultilog.id] = {
const agentCoValue = new CoValue(getAgentCoValueHeader(agent), this);
this.coValues[agentCoValue.id] = {
state: "loaded",
multilog: agentMultilog,
coValue: agentCoValue,
};
}
createMultiLog(header: MultiLogHeader): MultiLog {
const multilog = new MultiLog(header, this);
this.multilogs[multilog.id] = { state: "loaded", multilog };
createCoValue(header: CoValueHeader): CoValue {
const coValue = new CoValue(header, this);
this.coValues[coValue.id] = { state: "loaded", coValue: coValue };
this.syncMultiLog(multilog);
this.syncCoValue(coValue);
return multilog;
return coValue;
}
loadMultiLog(id: MultiLogID): Promise<MultiLog> {
let entry = this.multilogs[id];
loadCoValue(id: RawCoValueID): Promise<CoValue> {
let entry = this.coValues[id];
if (!entry) {
entry = newLoadingState();
this.multilogs[id] = entry;
this.coValues[id] = entry;
for (const peer of Object.values(this.peers)) {
peer.outgoing
.write({
action: "subscribe",
knownState: {
multilogID: id,
coValueID: id,
header: false,
sessions: {},
},
@@ -85,26 +85,26 @@ export class LocalNode {
}
}
if (entry.state === "loaded") {
return Promise.resolve(entry.multilog);
return Promise.resolve(entry.coValue);
}
return entry.done;
}
expectMultiLogLoaded(id: MultiLogID, expectation?: string): MultiLog {
const entry = this.multilogs[id];
expectCoValueLoaded(id: RawCoValueID, expectation?: string): CoValue {
const entry = this.coValues[id];
if (!entry) {
throw new Error(
`${expectation ? expectation + ": " : ""}Unknown multilog ${id}`
`${expectation ? expectation + ": " : ""}Unknown CoValue ${id}`
);
}
if (entry.state === "loading") {
throw new Error(
`${
expectation ? expectation + ": " : ""
}Multilog ${id} not yet loaded`
}CoValue ${id} not yet loaded`
);
}
return entry.multilog;
return entry.coValue;
}
addKnownAgent(agent: Agent) {
@@ -113,13 +113,13 @@ export class LocalNode {
}
createTeam(): Team {
const teamMultilog = this.createMultiLog({
const teamCoValue = this.createCoValue({
type: "comap",
ruleset: { type: "team", initialAdmin: this.agentID },
meta: null,
});
let teamContent = expectTeamContent(teamMultilog.getCurrentContent());
let teamContent = expectTeamContent(teamCoValue.getCurrentContent());
teamContent = teamContent.edit((editable) => {
editable.set(this.agentID, "admin", "trusting");
@@ -130,8 +130,8 @@ export class LocalNode {
this.agentCredential.recipientSecret,
new Set([getAgent(this.agentCredential).recipientID]),
{
in: teamMultilog.id,
tx: teamMultilog.nextTransactionID(),
in: teamCoValue.id,
tx: teamCoValue.nextTransactionID(),
}
);
@@ -156,7 +156,7 @@ export class LocalNode {
this.peers[peer.id] = peerState;
if (peer.role === "server") {
for (const entry of Object.values(this.multilogs)) {
for (const entry of Object.values(this.coValues)) {
if (entry.state === "loading") {
continue;
}
@@ -164,15 +164,15 @@ export class LocalNode {
peerState.outgoing
.write({
action: "subscribe",
knownState: entry.multilog.knownState(),
knownState: entry.coValue.knownState(),
})
.catch((e) => {
// TODO: handle error
console.error("Error writing to peer", e);
});
peerState.optimisticKnownStates[entry.multilog.id] = {
multilogID: entry.multilog.id,
peerState.optimisticKnownStates[entry.coValue.id] = {
coValueID: entry.coValue.id,
header: false,
sessions: {},
};
@@ -217,20 +217,20 @@ export class LocalNode {
handleSubscribe(
msg: SubscribeMessage,
peer: PeerState,
asDependencyOf?: MultiLogID
asDependencyOf?: RawCoValueID
): SyncMessage[] {
const entry = this.multilogs[msg.knownState.multilogID];
const entry = this.coValues[msg.knownState.coValueID];
if (!entry || entry.state === "loading") {
if (!entry) {
this.multilogs[msg.knownState.multilogID] = newLoadingState();
this.coValues[msg.knownState.coValueID] = newLoadingState();
}
return [
{
action: "subscribeResponse",
knownState: {
multilogID: msg.knownState.multilogID,
coValueID: msg.knownState.coValueID,
header: false,
sessions: {},
},
@@ -238,39 +238,39 @@ export class LocalNode {
];
}
peer.optimisticKnownStates[entry.multilog.id] =
entry.multilog.knownState();
peer.optimisticKnownStates[entry.coValue.id] =
entry.coValue.knownState();
const newContent = entry.multilog.newContentSince(msg.knownState);
const newContent = entry.coValue.newContentSince(msg.knownState);
const dependedOnMultilogs =
entry.multilog.header.ruleset.type === "team"
? expectTeamContent(entry.multilog.getCurrentContent())
const dependedOnCoValues =
entry.coValue.header.ruleset.type === "team"
? expectTeamContent(entry.coValue.getCurrentContent())
.keys()
.filter((k): k is AgentID => k.startsWith("agent_"))
.map((agent) => agentIDasMultiLogID(agent))
: entry.multilog.header.ruleset.type === "ownedByTeam"
? [entry.multilog.header.ruleset.team]
.map((agent) => agentIDAsCoValueID(agent))
: entry.coValue.header.ruleset.type === "ownedByTeam"
? [entry.coValue.header.ruleset.team]
: [];
return [
...dependedOnMultilogs.flatMap((multilogID) =>
...dependedOnCoValues.flatMap((coValueID) =>
this.handleSubscribe(
{
action: "subscribe",
knownState: {
multilogID,
coValueID,
header: false,
sessions: {},
},
},
peer,
asDependencyOf || msg.knownState.multilogID
asDependencyOf || msg.knownState.coValueID
)
),
{
action: "subscribeResponse",
knownState: entry.multilog.knownState(),
knownState: entry.coValue.knownState(),
asDependencyOf,
},
...(newContent ? [newContent] : []),
@@ -281,70 +281,70 @@ export class LocalNode {
msg: SubscribeResponseMessage,
peer: PeerState
): SyncMessage[] {
let entry = this.multilogs[msg.knownState.multilogID];
let entry = this.coValues[msg.knownState.coValueID];
if (!entry) {
if (msg.asDependencyOf) {
if (this.multilogs[msg.asDependencyOf]) {
if (this.coValues[msg.asDependencyOf]) {
entry = newLoadingState();
this.multilogs[msg.knownState.multilogID] = entry;
this.coValues[msg.knownState.coValueID] = entry;
}
} else {
throw new Error(
"Expected multilog entry to be created, missing subscribe?"
"Expected coValue entry to be created, missing subscribe?"
);
}
}
if (entry.state === "loading") {
peer.optimisticKnownStates[msg.knownState.multilogID] =
peer.optimisticKnownStates[msg.knownState.coValueID] =
msg.knownState;
return [];
}
const newContent = entry.multilog.newContentSince(msg.knownState);
peer.optimisticKnownStates[msg.knownState.multilogID] =
combinedKnownStates(msg.knownState, entry.multilog.knownState());
const newContent = entry.coValue.newContentSince(msg.knownState);
peer.optimisticKnownStates[msg.knownState.coValueID] =
combinedKnownStates(msg.knownState, entry.coValue.knownState());
return newContent ? [newContent] : [];
}
handleNewContent(msg: NewContentMessage): SyncMessage[] {
let entry = this.multilogs[msg.multilogID];
let entry = this.coValues[msg.coValueID];
if (!entry) {
throw new Error(
"Expected multilog entry to be created, missing subscribe?"
"Expected coValue entry to be created, missing subscribe?"
);
}
let resolveAfterDone: ((multilog: MultiLog) => void) | undefined;
let resolveAfterDone: ((coValue: CoValue) => void) | undefined;
if (entry.state === "loading") {
if (!msg.header) {
throw new Error("Expected header to be sent in first message");
}
const multilog = new MultiLog(msg.header, this);
const coValue = new CoValue(msg.header, this);
resolveAfterDone = entry.resolve;
entry = {
state: "loaded",
multilog,
coValue: coValue,
};
this.multilogs[msg.multilogID] = entry;
this.coValues[msg.coValueID] = entry;
}
const multilog = entry.multilog;
const coValue = entry.coValue;
let invalidStateAssumed = false;
for (const sessionID of Object.keys(msg.newContent) as SessionID[]) {
const ourKnownTxIdx =
multilog.sessions[sessionID]?.transactions.length;
coValue.sessions[sessionID]?.transactions.length;
const theirFirstNewTxIdx = msg.newContent[sessionID].after;
if ((ourKnownTxIdx || 0) < theirFirstNewTxIdx) {
@@ -361,7 +361,7 @@ export class LocalNode {
alreadyKnownOffset
);
const success = multilog.tryAddTransactions(
const success = coValue.tryAddTransactions(
sessionID,
newTransactions,
msg.newContent[sessionID].lastHash,
@@ -375,14 +375,14 @@ export class LocalNode {
}
if (resolveAfterDone) {
resolveAfterDone(multilog);
resolveAfterDone(coValue);
}
return invalidStateAssumed
? [
{
action: "wrongAssumedKnownState",
knownState: multilog.knownState(),
knownState: coValue.knownState(),
},
]
: [];
@@ -392,12 +392,12 @@ export class LocalNode {
msg: WrongAssumedKnownStateMessage,
peer: PeerState
): SyncMessage[] {
const multilog = this.expectMultiLogLoaded(msg.knownState.multilogID);
const coValue = this.expectCoValueLoaded(msg.knownState.coValueID);
peer.optimisticKnownStates[msg.knownState.multilogID] =
combinedKnownStates(msg.knownState, multilog.knownState());
peer.optimisticKnownStates[msg.knownState.coValueID] =
combinedKnownStates(msg.knownState, coValue.knownState());
const newContent = multilog.newContentSince(msg.knownState);
const newContent = coValue.newContentSince(msg.knownState);
return newContent ? [newContent] : [];
}
@@ -406,28 +406,28 @@ export class LocalNode {
throw new Error("Method not implemented.");
}
async syncMultiLog(multilog: MultiLog) {
async syncCoValue(coValue: CoValue) {
for (const peer of Object.values(this.peers)) {
const optimisticKnownState =
peer.optimisticKnownStates[multilog.id];
peer.optimisticKnownStates[coValue.id];
if (optimisticKnownState || peer.role === "server") {
const newContent =
multilog.newContentSince(optimisticKnownState);
coValue.newContentSince(optimisticKnownState);
peer.optimisticKnownStates[multilog.id] = peer
.optimisticKnownStates[multilog.id]
peer.optimisticKnownStates[coValue.id] = peer
.optimisticKnownStates[coValue.id]
? combinedKnownStates(
peer.optimisticKnownStates[multilog.id],
multilog.knownState()
peer.optimisticKnownStates[coValue.id],
coValue.knownState()
)
: multilog.knownState();
: coValue.knownState();
if (!optimisticKnownState && peer.role === "server") {
// auto-subscribe
await peer.outgoing.write({
action: "subscribe",
knownState: multilog.knownState(),
knownState: coValue.knownState(),
});
}
@@ -444,21 +444,21 @@ export class LocalNode {
): LocalNode {
const newNode = new LocalNode(agentCredential, ownSessionID);
newNode.multilogs = Object.fromEntries(
Object.entries(this.multilogs)
newNode.coValues = Object.fromEntries(
Object.entries(this.coValues)
.map(([id, entry]) => {
if (entry.state === "loading") {
return undefined;
}
const newMultilog = new MultiLog(
entry.multilog.header,
const newCoValue = new CoValue(
entry.coValue.header,
newNode
);
newMultilog.sessions = entry.multilog.sessions;
newCoValue.sessions = entry.coValue.sessions;
return [id, { state: "loaded", multilog: newMultilog }];
return [id, { state: "loaded", coValue: newCoValue }];
})
.filter((x): x is Exclude<typeof x, undefined> => !!x)
);
@@ -472,18 +472,18 @@ export class LocalNode {
}
}
type MultilogState =
type CoValueState =
| {
state: "loading";
done: Promise<MultiLog>;
resolve: (multilog: MultiLog) => void;
done: Promise<CoValue>;
resolve: (coValue: CoValue) => void;
}
| { state: "loaded"; multilog: MultiLog };
| { state: "loaded"; coValue: CoValue };
function newLoadingState(): MultilogState {
let resolve: (multilog: MultiLog) => void;
function newLoadingState(): CoValueState {
let resolve: (coValue: CoValue) => void;
const promise = new Promise<MultiLog>((r) => {
const promise = new Promise<CoValue>((r) => {
resolve = r;
});

View File

@@ -4,9 +4,9 @@ import {
getAgentID,
newRandomAgentCredential,
newRandomSessionID,
} from "./multilog";
} from "./coValue";
import { LocalNode } from "./node";
import { expectMap } from "./coValue";
import { expectMap } from "./contentType";
import { expectTeamContent } from "./permissions";
import {
getRecipientID,
@@ -44,7 +44,7 @@ function newTeam() {
const node = new LocalNode(admin, newRandomSessionID(adminID));
const team = node.createMultiLog({
const team = node.createCoValue({
type: "comap",
ruleset: { type: "team", initialAdmin: adminID },
meta: null,
@@ -351,7 +351,7 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader
test("Admins can write to an object that is owned by their team", () => {
const { node, team } = newTeam();
const childObject = node.createMultiLog({
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
@@ -393,7 +393,7 @@ test("Writers can write to an object that is owned by their team", () => {
expect(editable.get(writerID)).toEqual("writer");
});
const childObject = node.createMultiLog({
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
@@ -431,7 +431,7 @@ test("Writers can write to an object that is owned by their team (high level)",
const childObject = team.createMap();
let childObjectAsWriter = expectMap(
childObject.multiLog
childObject.coValue
.testWithDifferentCredentials(writer, newRandomSessionID(writerID))
.getCurrentContent()
);
@@ -455,7 +455,7 @@ test("Readers can not write to an object that is owned by their team", () => {
expect(editable.get(readerID)).toEqual("reader");
});
const childObject = node.createMultiLog({
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
@@ -493,7 +493,7 @@ test("Readers can not write to an object that is owned by their team (high level
const childObject = team.createMap();
let childObjectAsReader = expectMap(
childObject.multiLog
childObject.coValue
.testWithDifferentCredentials(reader, newRandomSessionID(readerID))
.getCurrentContent()
);
@@ -530,7 +530,7 @@ test("Admins can set team read key and then use it to create and read private tr
expect(team.getCurrentReadKey().secret).toEqual(readKey);
});
const childObject = node.createMultiLog({
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
@@ -588,7 +588,7 @@ test("Admins can set team read key and then writers can use it to create and rea
editable.set("readKey", { keyID: readKeyID, revelation }, "trusting");
});
const childObject = node.createMultiLog({
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
@@ -628,7 +628,7 @@ test("Admins can set team read key and then writers can use it to create and rea
const childObject = team.createMap();
let childObjectAsWriter = expectMap(
childObject.multiLog
childObject.coValue
.testWithDifferentCredentials(writer, newRandomSessionID(writerID))
.getCurrentContent()
);
@@ -669,7 +669,7 @@ test("Admins can set team read key and then use it to create private transaction
editable.set("readKey", { keyID: readKeyID, revelation }, "trusting");
});
const childObject = node.createMultiLog({
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
@@ -711,7 +711,7 @@ test("Admins can set team read key and then use it to create private transaction
expect(editable.get("foo")).toEqual("bar");
});
const childContentAsReader = expectMap(childObject.multiLog.testWithDifferentCredentials(
const childContentAsReader = expectMap(childObject.coValue.testWithDifferentCredentials(
reader,
newRandomSessionID(readerID)
).getCurrentContent());
@@ -769,7 +769,7 @@ test("Admins can set team read key and then use it to create private transaction
);
});
const childObject = node.createMultiLog({
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
@@ -827,7 +827,7 @@ test("Admins can set team read key and then use it to create private transaction
expect(editable.get("foo")).toEqual("bar");
});
const childContentAsReader1 = expectMap(childObject.multiLog.testWithDifferentCredentials(
const childContentAsReader1 = expectMap(childObject.coValue.testWithDifferentCredentials(
reader1,
newRandomSessionID(reader1ID)
).getCurrentContent());
@@ -836,7 +836,7 @@ test("Admins can set team read key and then use it to create private transaction
team.addMember(reader2ID, "reader");
const childContentAsReader2 = expectMap(childObject.multiLog.testWithDifferentCredentials(
const childContentAsReader2 = expectMap(childObject.coValue.testWithDifferentCredentials(
reader2,
newRandomSessionID(reader2ID)
).getCurrentContent());
@@ -869,7 +869,7 @@ test("Admins can set team read key, make a private transaction in an owned objec
expect(team.getCurrentReadKey().secret).toEqual(readKey);
});
const childObject = node.createMultiLog({
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
@@ -923,7 +923,7 @@ test("Admins can set team read key, make a private transaction in an owned objec
let childObject = team.createMap();
const firstReadKey = childObject.multiLog.getCurrentReadKey();
const firstReadKey = childObject.coValue.getCurrentReadKey();
childObject = childObject.edit((editable) => {
editable.set("foo", "bar", "private");
@@ -934,7 +934,7 @@ test("Admins can set team read key, make a private transaction in an owned objec
team.rotateReadKey();
expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(firstReadKey);
expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey);
childObject = childObject.edit((editable) => {
editable.set("foo2", "bar2", "private");
@@ -948,7 +948,7 @@ test("Admins can set team read key, make a private transaction in an owned objec
test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, add a reader, make another private transaction in the owned object, and both can be read by the reader", () => {
const { node, team, admin, adminID } = newTeam();
const childObject = node.createMultiLog({
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
@@ -1052,7 +1052,7 @@ test("Admins can set team read key, make a private transaction in an owned objec
let childObject = team.createMap();
const firstReadKey = childObject.multiLog.getCurrentReadKey();
const firstReadKey = childObject.coValue.getCurrentReadKey();
childObject = childObject.edit((editable) => {
editable.set("foo", "bar", "private");
@@ -1063,7 +1063,7 @@ test("Admins can set team read key, make a private transaction in an owned objec
team.rotateReadKey();
expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(firstReadKey);
expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey);
const reader = newRandomAgentCredential();
const readerID = getAgentID(getAgent(reader));
@@ -1077,7 +1077,7 @@ test("Admins can set team read key, make a private transaction in an owned objec
expect(editable.get("foo2")).toEqual("bar2");
});
const childContentAsReader = expectMap(childObject.multiLog.testWithDifferentCredentials(
const childContentAsReader = expectMap(childObject.coValue.testWithDifferentCredentials(
reader,
newRandomSessionID(readerID)
).getCurrentContent());
@@ -1090,7 +1090,7 @@ test("Admins can set team read key, make a private transaction in an owned objec
test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions", () => {
const { node, team, admin, adminID } = newTeam();
const childObject = node.createMultiLog({
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
@@ -1192,7 +1192,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec
expect(editable.get("foo2")).toEqual("bar2");
});
// TODO: make sure these instances of multilogs sync between each other so this isn't necessary?
// TODO: make sure these instances of coValues sync between each other so this isn't necessary?
childObjectAsReader = childObject.testWithDifferentCredentials(
reader,
newRandomSessionID(readerID)
@@ -1228,7 +1228,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec
team.rotateReadKey();
const secondReadKey = childObject.multiLog.getCurrentReadKey();
const secondReadKey = childObject.coValue.getCurrentReadKey();
const reader = newRandomAgentCredential();
const readerID = getAgentID(getAgent(reader));
@@ -1251,14 +1251,14 @@ test("Admins can set team read rey, make a private transaction in an owned objec
team.removeMember(readerID);
expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(secondReadKey);
expect(childObject.coValue.getCurrentReadKey()).not.toEqual(secondReadKey);
childObject = childObject.edit((editable) => {
editable.set("foo3", "bar3", "private");
expect(editable.get("foo3")).toEqual("bar3");
});
const childContentAsReader2 = expectMap(childObject.multiLog.testWithDifferentCredentials(
const childContentAsReader2 = expectMap(childObject.coValue.testWithDifferentCredentials(
reader2,
newRandomSessionID(reader2ID)
).getCurrentContent());
@@ -1267,7 +1267,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec
expect(childContentAsReader2.get("foo2")).toEqual("bar2");
expect(childContentAsReader2.get("foo3")).toEqual("bar3");
expect(() => childObject.multiLog.testWithDifferentCredentials(
expect(() => childObject.coValue.testWithDifferentCredentials(
reader,
newRandomSessionID(readerID)
).getCurrentContent()).toThrow(/readKey (.+?) not revealed for (.+?)/);

View File

@@ -1,4 +1,4 @@
import { CoMap, CoValue, MapOpPayload } from "./coValue";
import { CoMap, ContentType, MapOpPayload } from "./contentType";
import { JsonValue } from "./jsonValue";
import {
Encrypted,
@@ -15,19 +15,19 @@ import {
import {
AgentCredential,
AgentID,
MultiLog,
MultiLogID,
CoValue,
RawCoValueID,
SessionID,
Transaction,
TransactionID,
TrustingTransaction,
agentIDfromSessionID,
} from "./multilog";
} from "./coValue";
import { LocalNode } from ".";
export type PermissionsDef =
| { type: "team"; initialAdmin: AgentID; parentTeams?: MultiLogID[] }
| { type: "ownedByTeam"; team: MultiLogID }
| { type: "team"; initialAdmin: AgentID; parentTeams?: RawCoValueID[] }
| { type: "ownedByTeam"; team: RawCoValueID }
| {
type: "agent";
initialSignatoryID: SignatoryID;
@@ -38,11 +38,11 @@ export type PermissionsDef =
export type Role = "reader" | "writer" | "admin" | "revoked";
export function determineValidTransactions(
multilog: MultiLog
coValue: CoValue
): { txID: TransactionID; tx: Transaction }[] {
if (multilog.header.ruleset.type === "team") {
if (coValue.header.ruleset.type === "team") {
const allTrustingTransactionsSorted = Object.entries(
multilog.sessions
coValue.sessions
).flatMap(([sessionID, sessionLog]) => {
return sessionLog.transactions
.map((tx, txIndex) => ({ sessionID, txIndex, tx }))
@@ -64,7 +64,7 @@ export function determineValidTransactions(
return a.tx.madeAt - b.tx.madeAt;
});
const initialAdmin = multilog.header.ruleset.initialAdmin;
const initialAdmin = coValue.header.ruleset.initialAdmin;
if (!initialAdmin) {
throw new Error("Team must have initialAdmin");
@@ -153,10 +153,10 @@ export function determineValidTransactions(
}
return validTransactions;
} else if (multilog.header.ruleset.type === "ownedByTeam") {
} else if (coValue.header.ruleset.type === "ownedByTeam") {
const teamContent =
multilog.node.expectMultiLogLoaded(
multilog.header.ruleset.team,
coValue.node.expectCoValueLoaded(
coValue.header.ruleset.team,
"Determining valid transaction in owned object but its team wasn't loaded"
).getCurrentContent();
@@ -164,7 +164,7 @@ export function determineValidTransactions(
throw new Error("Team must be a map");
}
return Object.entries(multilog.sessions).flatMap(
return Object.entries(coValue.sessions).flatMap(
([sessionID, sessionLog]) => {
const transactor = agentIDfromSessionID(sessionID as SessionID);
return sessionLog.transactions
@@ -185,8 +185,8 @@ export function determineValidTransactions(
}));
}
);
} else if (multilog.header.ruleset.type === "unsafeAllowAll") {
return Object.entries(multilog.sessions).flatMap(
} else if (coValue.header.ruleset.type === "unsafeAllowAll") {
return Object.entries(coValue.sessions).flatMap(
([sessionID, sessionLog]) => {
return sessionLog.transactions.map((tx, txIndex) => ({
txID: { sessionID: sessionID as SessionID, txIndex },
@@ -194,11 +194,11 @@ export function determineValidTransactions(
}));
}
);
} else if (multilog.header.ruleset.type === "agent") {
} else if (coValue.header.ruleset.type === "agent") {
// TODO
return [];
} else {
throw new Error("Unknown ruleset type " + (multilog.header.ruleset as any).type);
throw new Error("Unknown ruleset type " + (coValue.header.ruleset as any).type);
}
}
@@ -215,7 +215,7 @@ export type TeamContent = { [key: AgentID]: Role } & {
};
};
export function expectTeamContent(content: CoValue): CoMap<TeamContent, {}> {
export function expectTeamContent(content: ContentType): CoMap<TeamContent, {}> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
@@ -232,7 +232,7 @@ export class Team {
this.node = node;
}
get id(): MultiLogID {
get id(): RawCoValueID {
return this.teamMap.id;
}
@@ -249,15 +249,15 @@ export class Team {
throw new Error("Failed to set role");
}
const currentReadKey = this.teamMap.multiLog.getCurrentReadKey();
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
const revelation = seal(
currentReadKey.secret,
this.teamMap.multiLog.node.agentCredential.recipientSecret,
this.teamMap.coValue.node.agentCredential.recipientSecret,
new Set([agent.recipientID]),
{
in: this.teamMap.multiLog.id,
tx: this.teamMap.multiLog.nextTransactionID(),
in: this.teamMap.coValue.id,
tx: this.teamMap.coValue.nextTransactionID(),
}
);
@@ -281,21 +281,21 @@ export class Team {
}
}) as AgentID[];
const currentReadKey = this.teamMap.multiLog.getCurrentReadKey();
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
const newReadKey = newRandomKeySecret();
const newReadKeyRevelation = seal(
newReadKey.secret,
this.teamMap.multiLog.node.agentCredential.recipientSecret,
this.teamMap.coValue.node.agentCredential.recipientSecret,
new Set(
currentlyPermittedReaders.map(
(reader) => this.node.knownAgents[reader].recipientID
)
),
{
in: this.teamMap.multiLog.id,
tx: this.teamMap.multiLog.nextTransactionID(),
in: this.teamMap.coValue.id,
tx: this.teamMap.coValue.nextTransactionID(),
}
);
@@ -329,7 +329,7 @@ export class Team {
meta?: M
): CoMap<M, Meta> {
return this.node
.createMultiLog({
.createCoValue({
type: "comap",
ruleset: {
type: "ownedByTeam",
@@ -346,7 +346,7 @@ export class Team {
): Team {
return new Team(
expectTeamContent(
this.teamMap.multiLog
this.teamMap.coValue
.testWithDifferentCredentials(credential, sessionId)
.getCurrentContent()
),

View File

@@ -4,10 +4,10 @@ import {
getAgentID,
newRandomAgentCredential,
newRandomSessionID,
} from "./multilog";
} from "./coValue";
import { LocalNode } from "./node";
import { Peer, SyncMessage } from "./sync";
import { MapOpPayload, expectMap } from "./coValue";
import { MapOpPayload, expectMap } from "./contentType";
test(
"Node replies with initial tx and header to empty subscribe",
@@ -40,7 +40,7 @@ test(
await writer.write({
action: "subscribe",
knownState: {
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
header: false,
sessions: {},
},
@@ -57,14 +57,14 @@ test(
expect(subscribeResponseMsg.value).toEqual({
action: "subscribeResponse",
knownState: map.multiLog.knownState(),
knownState: map.coValue.knownState(),
} satisfies SyncMessage);
const newContentMsg = await reader.read();
expect(newContentMsg.value).toEqual({
action: "newContent",
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
header: {
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
@@ -76,7 +76,7 @@ test(
newTransactions: [
{
privacy: "trusting",
madeAt: map.multiLog.sessions[node.ownSessionID]
madeAt: map.coValue.sessions[node.ownSessionID]
.transactions[0].madeAt,
changes: [
{
@@ -88,9 +88,9 @@ test(
},
],
lastHash:
map.multiLog.sessions[node.ownSessionID].lastHash!,
map.coValue.sessions[node.ownSessionID].lastHash!,
lastSignature:
map.multiLog.sessions[node.ownSessionID].lastSignature!,
map.coValue.sessions[node.ownSessionID].lastSignature!,
},
},
} satisfies SyncMessage);
@@ -128,7 +128,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
await writer.write({
action: "subscribe",
knownState: {
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
header: true,
sessions: {
[node.ownSessionID]: 1,
@@ -147,14 +147,14 @@ test("Node replies with only new tx to subscribe with some known state", async (
expect(mapSubscribeResponseMsg.value).toEqual({
action: "subscribeResponse",
knownState: map.multiLog.knownState(),
knownState: map.coValue.knownState(),
} satisfies SyncMessage);
const mapNewContentMsg = await reader.read();
expect(mapNewContentMsg.value).toEqual({
action: "newContent",
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
header: undefined,
newContent: {
[node.ownSessionID]: {
@@ -162,7 +162,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
newTransactions: [
{
privacy: "trusting",
madeAt: map.multiLog.sessions[node.ownSessionID]
madeAt: map.coValue.sessions[node.ownSessionID]
.transactions[1].madeAt,
changes: [
{
@@ -173,15 +173,15 @@ test("Node replies with only new tx to subscribe with some known state", async (
],
},
],
lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!,
lastHash: map.coValue.sessions[node.ownSessionID].lastHash!,
lastSignature:
map.multiLog.sessions[node.ownSessionID].lastSignature!,
map.coValue.sessions[node.ownSessionID].lastSignature!,
},
},
} satisfies SyncMessage);
});
test.skip("TODO: node only replies with new tx to subscribe with some known state, even in the depended on multilogs", () => {});
test.skip("TODO: node only replies with new tx to subscribe with some known state, even in the depended on coValues", () => {});
test("After subscribing, node sends own known state and new txs to peer", async () => {
const admin = newRandomAgentCredential();
@@ -208,7 +208,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
await writer.write({
action: "subscribe",
knownState: {
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
header: false,
sessions: {
[node.ownSessionID]: 0,
@@ -227,15 +227,15 @@ test("After subscribing, node sends own known state and new txs to peer", async
expect(mapSubscribeResponseMsg.value).toEqual({
action: "subscribeResponse",
knownState: map.multiLog.knownState(),
knownState: map.coValue.knownState(),
} satisfies SyncMessage);
const mapNewContentHeaderOnlyMsg = await reader.read();
expect(mapNewContentHeaderOnlyMsg.value).toEqual({
action: "newContent",
multilogID: map.multiLog.id,
header: map.multiLog.header,
coValueID: map.coValue.id,
header: map.coValue.header,
newContent: {},
} satisfies SyncMessage);
@@ -247,14 +247,14 @@ test("After subscribing, node sends own known state and new txs to peer", async
expect(mapEditMsg1.value).toEqual({
action: "newContent",
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
newContent: {
[node.ownSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting",
madeAt: map.multiLog.sessions[node.ownSessionID]
madeAt: map.coValue.sessions[node.ownSessionID]
.transactions[0].madeAt,
changes: [
{
@@ -265,9 +265,9 @@ test("After subscribing, node sends own known state and new txs to peer", async
],
},
],
lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!,
lastHash: map.coValue.sessions[node.ownSessionID].lastHash!,
lastSignature:
map.multiLog.sessions[node.ownSessionID].lastSignature!,
map.coValue.sessions[node.ownSessionID].lastSignature!,
},
},
} satisfies SyncMessage);
@@ -280,14 +280,14 @@ test("After subscribing, node sends own known state and new txs to peer", async
expect(mapEditMsg2.value).toEqual({
action: "newContent",
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
newContent: {
[node.ownSessionID]: {
after: 1,
newTransactions: [
{
privacy: "trusting",
madeAt: map.multiLog.sessions[node.ownSessionID]
madeAt: map.coValue.sessions[node.ownSessionID]
.transactions[1].madeAt,
changes: [
{
@@ -298,9 +298,9 @@ test("After subscribing, node sends own known state and new txs to peer", async
],
},
],
lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!,
lastHash: map.coValue.sessions[node.ownSessionID].lastHash!,
lastSignature:
map.multiLog.sessions[node.ownSessionID].lastSignature!,
map.coValue.sessions[node.ownSessionID].lastSignature!,
},
},
} satisfies SyncMessage);
@@ -335,7 +335,7 @@ test("Client replies with known new content to subscribeResponse from server", a
await writer.write({
action: "subscribeResponse",
knownState: {
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
header: false,
sessions: {
[node.ownSessionID]: 0,
@@ -349,15 +349,15 @@ test("Client replies with known new content to subscribeResponse from server", a
expect(msg1.value).toEqual({
action: "newContent",
multilogID: map.multiLog.id,
header: map.multiLog.header,
coValueID: map.coValue.id,
header: map.coValue.header,
newContent: {
[node.ownSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting",
madeAt: map.multiLog.sessions[node.ownSessionID]
madeAt: map.coValue.sessions[node.ownSessionID]
.transactions[0].madeAt,
changes: [
{
@@ -368,9 +368,9 @@ test("Client replies with known new content to subscribeResponse from server", a
],
},
],
lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!,
lastHash: map.coValue.sessions[node.ownSessionID].lastHash!,
lastSignature:
map.multiLog.sessions[node.ownSessionID].lastSignature!,
map.coValue.sessions[node.ownSessionID].lastSignature!,
},
},
} satisfies SyncMessage);
@@ -401,7 +401,7 @@ test("No matter the optimistic known state, node respects invalid known state me
await writer.write({
action: "subscribe",
knownState: {
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
header: false,
sessions: {
[node.ownSessionID]: 0,
@@ -432,7 +432,7 @@ test("No matter the optimistic known state, node respects invalid known state me
await writer.write({
action: "wrongAssumedKnownState",
knownState: {
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
header: true,
sessions: {
[node.ownSessionID]: 1,
@@ -444,7 +444,7 @@ test("No matter the optimistic known state, node respects invalid known state me
expect(newContentAfterWrongAssumedState.value).toEqual({
action: "newContent",
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
header: undefined,
newContent: {
[node.ownSessionID]: {
@@ -452,7 +452,7 @@ test("No matter the optimistic known state, node respects invalid known state me
newTransactions: [
{
privacy: "trusting",
madeAt: map.multiLog.sessions[node.ownSessionID]
madeAt: map.coValue.sessions[node.ownSessionID]
.transactions[1].madeAt,
changes: [
{
@@ -463,15 +463,15 @@ test("No matter the optimistic known state, node respects invalid known state me
],
},
],
lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!,
lastHash: map.coValue.sessions[node.ownSessionID].lastHash!,
lastSignature:
map.multiLog.sessions[node.ownSessionID].lastSignature!,
map.coValue.sessions[node.ownSessionID].lastSignature!,
},
},
} satisfies SyncMessage);
});
test("If we add a peer, but it never subscribes to a multilog, it won't get any messages", async () => {
test("If we add a peer, but it never subscribes to a coValue, it won't get any messages", async () => {
const admin = newRandomAgentCredential();
const adminID = getAgentID(getAgent(admin));
@@ -500,7 +500,7 @@ test("If we add a peer, but it never subscribes to a multilog, it won't get any
await shouldNotResolve(reader.read(), { timeout: 50 });
});
test("If we add a server peer, all updates to all multilogs are sent to it, even if it doesn't subscribe", async () => {
test("If we add a server peer, all updates to all coValues are sent to it, even if it doesn't subscribe", async () => {
const admin = newRandomAgentCredential();
const adminID = getAgentID(getAgent(admin));
@@ -533,7 +533,7 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even
expect(subscribeMsg.value).toEqual({
action: "subscribe",
knownState: {
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
header: true,
sessions: {},
},
@@ -543,15 +543,15 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even
expect(newContentMsg.value).toEqual({
action: "newContent",
multilogID: map.multiLog.id,
header: map.multiLog.header,
coValueID: map.coValue.id,
header: map.coValue.header,
newContent: {
[node.ownSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting",
madeAt: map.multiLog.sessions[node.ownSessionID]
madeAt: map.coValue.sessions[node.ownSessionID]
.transactions[0].madeAt,
changes: [
{
@@ -562,15 +562,15 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even
],
},
],
lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!,
lastHash: map.coValue.sessions[node.ownSessionID].lastHash!,
lastSignature:
map.multiLog.sessions[node.ownSessionID].lastSignature!,
map.coValue.sessions[node.ownSessionID].lastSignature!,
},
},
} satisfies SyncMessage);
});
test("If we add a server peer, newly created multilogs are auto-subscribed to", async () => {
test("If we add a server peer, newly created coValues are auto-subscribed to", async () => {
const admin = newRandomAgentCredential();
const adminID = getAgentID(getAgent(admin));
@@ -600,22 +600,22 @@ test("If we add a server peer, newly created multilogs are auto-subscribed to",
expect(msg1.value).toEqual({
action: "subscribe",
knownState: map.multiLog.knownState(),
knownState: map.coValue.knownState(),
} satisfies SyncMessage);
const msg2 = await reader.read();
expect(msg2.value).toEqual({
action: "newContent",
multilogID: map.multiLog.id,
header: map.multiLog.header,
coValueID: map.coValue.id,
header: map.coValue.header,
newContent: {},
} satisfies SyncMessage);
});
test.skip("TODO: when receiving a subscribe response that is behind our optimistic state (due to already sent content), we ignore it", () => {});
test("When we connect a new server peer, we try to sync all existing multilogs to it", async () => {
test("When we connect a new server peer, we try to sync all existing coValues to it", async () => {
const admin = newRandomAgentCredential();
const adminID = getAgentID(getAgent(admin));
@@ -642,14 +642,14 @@ test("When we connect a new server peer, we try to sync all existing multilogs t
expect(teamSubscribeMessage.value).toEqual({
action: "subscribe",
knownState: team.teamMap.multiLog.knownState(),
knownState: team.teamMap.coValue.knownState(),
} satisfies SyncMessage);
const secondMessage = await reader.read();
expect(secondMessage.value).toEqual({
action: "subscribe",
knownState: map.multiLog.knownState(),
knownState: map.coValue.knownState(),
} satisfies SyncMessage);
});
@@ -678,7 +678,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
await writer.write({
action: "subscribe",
knownState: {
multilogID: map.multiLog.id,
coValueID: map.coValue.id,
header: true,
sessions: {
[node.ownSessionID]: 1,
@@ -696,11 +696,11 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
expect(mapSubscribeResponse.value).toEqual({
action: "subscribeResponse",
knownState: map.multiLog.knownState(),
knownState: map.coValue.knownState(),
} satisfies SyncMessage);
});
test("When replaying creation and transactions of a multilog as new content, the receiving peer integrates this information", async () => {
test("When replaying creation and transactions of a coValue as new content, the receiving peer integrates this information", async () => {
const admin = newRandomAgentCredential();
const adminID = getAgentID(getAgent(admin));
@@ -752,7 +752,7 @@ test("When replaying creation and transactions of a multilog as new content, the
await writer2.write(teamSubscribeMsg.value);
const teamSubscribeResponseMsg = await reader2.read();
expect(node2.multilogs[team.teamMap.multiLog.id]?.state).toEqual("loading");
expect(node2.coValues[team.teamMap.coValue.id]?.state).toEqual("loading");
const writer1 = inTx1.getWriter();
@@ -765,7 +765,7 @@ test("When replaying creation and transactions of a multilog as new content, the
const _mapSubscribeResponseMsg = await reader2.read();
await writer2.write(mapNewContentMsg.value);
expect(node2.multilogs[map.multiLog.id]?.state).toEqual("loading");
expect(node2.coValues[map.coValue.id]?.state).toEqual("loading");
await writer2.write(mapEditMsg.value);
@@ -773,12 +773,12 @@ test("When replaying creation and transactions of a multilog as new content, the
expect(
expectMap(
node2.expectMultiLogLoaded(map.multiLog.id).getCurrentContent()
node2.expectCoValueLoaded(map.coValue.id).getCurrentContent()
).get("hello")
).toEqual("world");
});
test("When loading a multilog on one node, the server node it is requested from replies with all the necessary depended on multilogs to make it work", async () => {
test("When loading a coValue on one node, the server node it is requested from replies with all the necessary depended on coValues to make it work", async () => {
const admin = newRandomAgentCredential();
const adminID = getAgentID(getAgent(admin));
@@ -798,11 +798,11 @@ test("When loading a multilog on one node, the server node it is requested from
node1.addPeer(node2asPeer);
node2.addPeer(node1asPeer);
await node2.loadMultiLog(map.multiLog.id);
await node2.loadCoValue(map.coValue.id);
expect(
expectMap(
node2.expectMultiLogLoaded(map.multiLog.id).getCurrentContent()
node2.expectCoValueLoaded(map.coValue.id).getCurrentContent()
).get("hello")
).toEqual("world");
});

View File

@@ -1,8 +1,8 @@
import { Hash, Signature } from "./crypto";
import { MultiLogHeader, MultiLogID, SessionID, Transaction } from "./multilog";
import { CoValueHeader, RawCoValueID, SessionID, Transaction } from "./coValue";
export type MultiLogKnownState = {
multilogID: MultiLogID;
export type CoValueKnownState = {
coValueID: RawCoValueID;
header: boolean;
sessions: { [sessionID: SessionID]: number };
};
@@ -16,19 +16,19 @@ export type SyncMessage =
export type SubscribeMessage = {
action: "subscribe";
knownState: MultiLogKnownState;
knownState: CoValueKnownState;
};
export type SubscribeResponseMessage = {
action: "subscribeResponse";
knownState: MultiLogKnownState;
asDependencyOf?: MultiLogID;
knownState: CoValueKnownState;
asDependencyOf?: RawCoValueID;
};
export type NewContentMessage = {
action: "newContent";
multilogID: MultiLogID;
header?: MultiLogHeader;
coValueID: RawCoValueID;
header?: CoValueHeader;
newContent: {
[sessionID: SessionID]: SessionNewContent;
};
@@ -44,12 +44,12 @@ export type SessionNewContent = {
export type WrongAssumedKnownStateMessage = {
action: "wrongAssumedKnownState";
knownState: MultiLogKnownState;
knownState: CoValueKnownState;
};
export type UnsubscribeMessage = {
action: "unsubscribe";
multilogID: MultiLogID;
coValueID: RawCoValueID;
};
export type PeerID = string;
@@ -63,15 +63,15 @@ export interface Peer {
export interface PeerState {
id: PeerID;
optimisticKnownStates: { [multilogID: MultiLogID]: MultiLogKnownState };
optimisticKnownStates: { [coValueID: RawCoValueID]: CoValueKnownState };
incoming: ReadableStream<SyncMessage>;
outgoing: WritableStreamDefaultWriter<SyncMessage>;
role: "peer" | "server" | "client";
}
export function weAreStrictlyAhead(
ourKnownState: MultiLogKnownState,
theirKnownState: MultiLogKnownState
ourKnownState: CoValueKnownState,
theirKnownState: CoValueKnownState
): boolean {
if (theirKnownState.header && !ourKnownState.header) {
return false;
@@ -94,8 +94,8 @@ export function weAreStrictlyAhead(
return true;
}
export function combinedKnownStates(stateA: MultiLogKnownState, stateB: MultiLogKnownState): MultiLogKnownState {
const sessionStates: MultiLogKnownState["sessions"] = {};
export function combinedKnownStates(stateA: CoValueKnownState, stateB: CoValueKnownState): CoValueKnownState {
const sessionStates: CoValueKnownState["sessions"] = {};
const allSessions = new Set([...Object.keys(stateA.sessions), ...Object.keys(stateB.sessions)] as SessionID[]);
@@ -107,7 +107,7 @@ export function combinedKnownStates(stateA: MultiLogKnownState, stateB: MultiLog
}
return {
multilogID: stateA.multilogID,
coValueID: stateA.coValueID,
header: stateA.header || stateB.header,
sessions: sessionStates,
};