Add a ledger/user type (#1915)

This adds a user type to the ledger. Each user is tracked by a UUID, and
has a human-specified and renameable username. Each user also
corresponds to a node in the graph, and has a list of aliase addresses
that should be merged into it using `graph.contractNodes`.

It's important that we track this in the ledger because the ledger needs
to reconcile when aliases are added or removed.

Test plan: Unit tests added. Quite a simple module.
This commit is contained in:
Dandelion Mané 2020-07-05 01:36:21 -07:00 committed by GitHub
parent 43d378ca93
commit e9c70d5327
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 158 additions and 0 deletions

105
src/ledger/user.js Normal file
View File

@ -0,0 +1,105 @@
// @flow
/**
* This module has a core data type identifying SourceCred users.
*
* The scope for this data type is to model:
* - a unique identifier for each user
* - a unique (renameable) username they choose
* - the address of every node they correspond to in the graph
*
* Unlike most other state in SourceCred, the User state is
* nondeterministically generated by SourceCred itself, and then persisted
* long-term within the instance.
*
* This is in contrast to Graph data, which usually comes from an external
* source, and is not persisted long-term, but instead is re-generated when
* needed.
*
* In particular, this kernel of user data is stored within the core ledger,
* since it's necessary to track consistently when tracking Grain distribution.
* This type should not grow to include all the data that the UI will
* eventually want to show; that should be kept in a different data store which
* isn't being used as a transaction ledger.
*
*/
import {
type Uuid,
parser as uuidParser,
random as randomUuid,
} from "../util/uuid";
import * as C from "../util/combo";
import {
type NodeAddressT,
NodeAddress,
type Node as GraphNode,
} from "../core/graph";
/**
* We validate usernames using GitHub-esque rules.
*/
export opaque type Username: string = string;
const USERNAME_PATTERN = /^@?([A-Za-z0-9-_]+)$/;
export type UserId = Uuid;
export type User = {|
// UUID, assigned when the user is created.
+id: UserId,
+name: Username,
// Every other node in the graph that this user corresponds to.
// Does not include the user's "own" address, i.e. the result
// of calling (userAddress(user.id)).
+aliases: $ReadOnlyArray<NodeAddressT>,
|};
// It's not in the typical [owner, name] format because it isn't provided by a plugin.
// Instead, it's a raw type owned by SourceCred project.
export const USER_PREFIX = NodeAddress.fromParts(["sourcecred", "USER"]);
/**
* Create a new user, assigning a random id.
*/
export function createUser(name: string): User {
return {
id: randomUuid(),
name,
aliases: [],
};
}
/**
* Parse a Username from a string.
*
* Throws an error if the username is invalid.
*/
export function usernameFromString(username: string): Username {
const re = new RegExp(USERNAME_PATTERN);
const match = re.exec(username);
if (match == null) {
throw new Error(`invalid username: ${username}`);
}
return match[1];
}
export function userAddress(id: UserId): NodeAddressT {
return NodeAddress.append(USER_PREFIX, id);
}
export function graphNode({id, name}: User): GraphNode {
return {
address: userAddress(id),
description: name,
timestampMs: null,
};
}
export const usernameParser: C.Parser<Username> = C.fmap(
C.string,
usernameFromString
);
export const userParser: C.Parser<User> = C.object({
id: uuidParser,
name: usernameParser,
aliases: C.array(C.fmap(C.string, NodeAddress.fromRaw)),
});

53
src/ledger/user.test.js Normal file
View File

@ -0,0 +1,53 @@
// @flow
import deepFreeze from "deep-freeze";
import {fromString as uuidFromString} from "../util/uuid";
import {NodeAddress} from "../core/graph";
import {
createUser,
userAddress,
usernameFromString,
USER_PREFIX,
graphNode,
type User,
} from "./user";
describe("ledger/user", () => {
const uuid = uuidFromString("YVZhbGlkVXVpZEF0TGFzdA");
const name = usernameFromString("foo");
const example: User = deepFreeze({
id: uuid,
name,
aliases: [NodeAddress.empty],
});
it("createUser works", () => {
const user = createUser(name);
expect(user.aliases).toEqual([]);
expect(user.name).toEqual(name);
// Verify it is a valid UUID
uuidFromString(user.id);
});
it("userAddress works", () => {
expect(userAddress(uuid)).toEqual(NodeAddress.append(USER_PREFIX, uuid));
});
it("graphNode works", () => {
const node = graphNode(example);
expect(node.description).toEqual(example.name);
expect(node.address).toEqual(userAddress(uuid));
expect(node.timestampMs).toEqual(null);
});
describe("usernameFromString", () => {
it("fails on invalid usernames", () => {
const bad = ["With Space", "With.Period", "A/Slash", ""];
for (const b of bad) {
expect(() => usernameFromString(b)).toThrowError("invalid username");
}
});
it("succeeds on valid usernames", () => {
const names = ["h", "hi_there", "ZaX99324cab"];
for (const n of names) {
expect(usernameFromString(n)).toEqual(n);
}
});
});
});