Add workspace as root node

This commit is contained in:
Hakan Shehu
2024-11-10 20:02:25 +01:00
parent 4f77314c6b
commit 35182bdb3d
25 changed files with 315 additions and 396 deletions

View File

@@ -17,6 +17,7 @@ const createNodesTable: Migration = {
.stored()
.references('nodes.id')
.onDelete('cascade')
.notNull()
)
.addColumn('index', 'text', (col) =>
col.generatedAlwaysAs(sql`json_extract(attributes, '$.index')`).stored()
@@ -140,15 +141,11 @@ const createNodePathsTable: Migration = {
AFTER INSERT ON nodes
FOR EACH ROW
BEGIN
-- Insert direct path from the new node to itself
INSERT INTO node_paths (ancestor_id, descendant_id, level)
VALUES (NEW.id, NEW.id, 0);
-- Insert paths from ancestors to the new node
INSERT INTO node_paths (ancestor_id, descendant_id, level)
SELECT ancestor_id, NEW.id, level + 1
FROM node_paths
WHERE descendant_id = NEW.parent_id;
WHERE descendant_id = NEW.parent_id AND ancestor_id <> NEW.id;
END;
`.execute(db);
@@ -166,7 +163,7 @@ const createNodePathsTable: Migration = {
INSERT INTO node_paths (ancestor_id, descendant_id, level)
SELECT ancestor_id, NEW.id, level + 1
FROM node_paths
WHERE descendant_id = NEW.parent_id;
WHERE descendant_id = NEW.parent_id AND ancestor_id <> NEW.id;
END;
`.execute(db);
},

View File

@@ -2,7 +2,7 @@ import { ColumnType, Insertable, Selectable, Updateable } from 'kysely';
interface NodeTable {
id: ColumnType<string, string, never>;
parent_id: ColumnType<string | null, never, never>;
parent_id: ColumnType<string, never, never>;
type: ColumnType<string, never, never>;
index: ColumnType<string | null, never, never>;
attributes: ColumnType<string, string, string>;

View File

@@ -3,7 +3,6 @@ import { databaseManager } from '@/main/data/database-manager';
import { EmailLoginMutationInput } from '@/operations/mutations/email-login';
import { MutationChange, MutationHandler, MutationResult } from '@/main/types';
import { httpClient } from '@/lib/http-client';
import { toUint8Array } from 'js-base64';
export class EmailLoginMutationHandler
implements MutationHandler<EmailLoginMutationInput>
@@ -84,37 +83,6 @@ export class EmailLoginMutationHandler
});
});
for (const workspace of data.workspaces) {
const workspaceDatabase = await databaseManager.getWorkspaceDatabase(
workspace.user.id
);
const user = workspace.user.node;
await workspaceDatabase
.insertInto('nodes')
.values({
id: user.id,
attributes: JSON.stringify(user.attributes),
state: toUint8Array(user.state),
created_at: user.createdAt,
created_by: user.createdBy,
updated_at: user.updatedAt,
updated_by: user.updatedBy,
server_created_at: user.serverCreatedAt,
server_updated_at: user.serverUpdatedAt,
version_id: user.versionId,
server_version_id: user.versionId,
})
.onConflict((cb) => cb.doNothing())
.execute();
changedTables.push({
type: 'workspace',
table: 'nodes',
userId: workspace.user.id,
});
}
return {
output: {
success: true,

View File

@@ -3,7 +3,6 @@ import { databaseManager } from '@/main/data/database-manager';
import { httpClient } from '@/lib/http-client';
import { EmailRegisterMutationInput } from '@/operations/mutations/email-register';
import { MutationChange, MutationHandler, MutationResult } from '@/main/types';
import { toUint8Array } from 'js-base64';
export class EmailRegisterMutationHandler
implements MutationHandler<EmailRegisterMutationInput>
@@ -85,37 +84,6 @@ export class EmailRegisterMutationHandler
});
});
for (const workspace of data.workspaces) {
const workspaceDatabase = await databaseManager.getWorkspaceDatabase(
workspace.user.id
);
const user = workspace.user.node;
await workspaceDatabase
.insertInto('nodes')
.values({
id: user.id,
attributes: JSON.stringify(user.attributes),
state: toUint8Array(user.state),
created_at: user.createdAt,
created_by: user.createdBy,
updated_at: user.updatedAt,
updated_by: user.updatedBy,
server_created_at: user.serverCreatedAt,
server_updated_at: user.serverUpdatedAt,
version_id: user.versionId,
server_version_id: user.versionId,
})
.onConflict((cb) => cb.doNothing())
.execute();
changedTables.push({
type: 'workspace',
table: 'nodes',
userId: workspace.user.id,
});
}
return {
output: {
success: true,

View File

@@ -3,8 +3,7 @@ import { socketManager } from '@/main/sockets/socket-manager';
import { hasInsertChanges, hasUpdateChanges } from '@/main/utils';
import { MutationHandler, MutationResult } from '@/main/types';
import { ServerNodeSyncMutationInput } from '@/operations/mutations/server-node-sync';
import { toUint8Array } from 'js-base64';
import * as Y from 'yjs';
import { YDoc } from '@colanode/crdt';
export class ServerNodeSyncMutationHandler
implements MutationHandler<ServerNodeSyncMutationInput>
@@ -44,18 +43,15 @@ export class ServerNodeSyncMutationHandler
.executeTakeFirst();
if (!existingNode) {
const doc = new Y.Doc({ guid: input.id });
const state = toUint8Array(input.state);
Y.applyUpdate(doc, state);
const attributesMap = doc.getMap('attributes');
const attributes = JSON.stringify(attributesMap.toJSON());
const ydoc = new YDoc(input.id, input.state);
const attributes = ydoc.getAttributes();
const state = ydoc.getState();
const result = await workspaceDatabase
.insertInto('nodes')
.values({
id: input.id,
attributes: attributes,
attributes: JSON.stringify(attributes),
state: state,
created_at: input.createdAt,
created_by: input.createdBy,
@@ -89,19 +85,17 @@ export class ServerNodeSyncMutationHandler
};
}
} else {
const doc = new Y.Doc({ guid: input.id });
Y.applyUpdate(doc, existingNode.state);
Y.applyUpdate(doc, toUint8Array(input.state));
const ydoc = new YDoc(input.id, existingNode.state);
ydoc.applyUpdate(input.state);
const attributesMap = doc.getMap('attributes');
const attributes = JSON.stringify(attributesMap.toJSON());
const state = Y.encodeStateAsUpdate(doc);
const attributes = ydoc.getAttributes();
const state = ydoc.getState();
const result = await workspaceDatabase
.updateTable('nodes')
.set({
state: state,
attributes: attributes,
attributes: JSON.stringify(attributes),
server_created_at: input.serverCreatedAt,
server_updated_at: input.serverUpdatedAt,
server_version_id: input.versionId,

View File

@@ -3,7 +3,6 @@ import { httpClient } from '@/lib/http-client';
import { WorkspaceCreateMutationInput } from '@/operations/mutations/workspace-create';
import { MutationHandler, MutationResult } from '@/main/types';
import { WorkspaceOutput } from '@/types/workspaces';
import { toUint8Array } from 'js-base64';
export class WorkspaceCreateMutationHandler
implements MutationHandler<WorkspaceCreateMutationInput>
@@ -60,29 +59,6 @@ export class WorkspaceCreateMutationHandler
.onConflict((cb) => cb.doNothing())
.execute();
const workspaceDatabase = await databaseManager.getWorkspaceDatabase(
data.user.id
);
const user = data.user.node;
await workspaceDatabase
.insertInto('nodes')
.values({
id: user.id,
attributes: JSON.stringify(user.attributes),
state: toUint8Array(user.state),
created_at: user.createdAt,
created_by: user.createdBy,
updated_at: user.updatedAt,
updated_by: user.updatedBy,
server_created_at: user.serverCreatedAt,
server_updated_at: user.serverUpdatedAt,
version_id: user.versionId,
server_version_id: user.versionId,
})
.onConflict((cb) => cb.doNothing())
.execute();
return {
output: {
id: data.id,
@@ -95,7 +71,7 @@ export class WorkspaceCreateMutationHandler
{
type: 'workspace',
table: 'nodes',
userId: user.id,
userId: data.user.id,
},
],
};

View File

@@ -11,6 +11,7 @@ import {
QueryHandler,
QueryResult,
} from '@/main/types';
import { NodeTypes } from '@colanode/core';
export class BreadcrumbListQueryHandler
implements QueryHandler<BreadcrumbListQueryInput>
@@ -79,6 +80,7 @@ export class BreadcrumbListQueryHandler
SELECT n.*
FROM nodes n
INNER JOIN breadcrumb_nodes b ON n.id = b.parent_id
WHERE n.type <> ${NodeTypes.Workspace}
)
SELECT n.*
FROM breadcrumb_nodes n;

View File

@@ -90,7 +90,7 @@ export class SidebarSpaceListQueryHandler
WITH space_nodes AS (
SELECT *
FROM nodes
WHERE parent_id IS NULL AND type = ${NodeTypes.Space}
WHERE type = ${NodeTypes.Space}
),
space_children_nodes AS (
SELECT *

View File

@@ -47,11 +47,7 @@ class NodeManager {
await workspaceDatabase.transaction().execute(async (transaction) => {
for (const input of inputs) {
const model = registry[input.attributes.type];
if (!model) {
throw new Error('Invalid node type');
}
const model = registry.getModel(input.attributes.type);
if (!model.schema.safeParse(input.attributes).success) {
throw new Error('Invalid attributes');
}
@@ -77,7 +73,7 @@ class NodeManager {
}
const ydoc = new YDoc(input.id);
ydoc.updateAttributes(model.schema, input.attributes);
ydoc.updateAttributes(input.attributes);
const createdAt = new Date().toISOString();
const versionId = generateId(IdType.Version);
@@ -190,11 +186,7 @@ class NodeManager {
const updatedAt = new Date().toISOString();
const updatedAttributes = updater(node.attributes);
const model = registry[node.type];
if (!model) {
throw new Error('Invalid node type');
}
const model = registry.getModel(node.type);
if (!model.schema.safeParse(updatedAttributes).success) {
throw new Error('Invalid attributes');
}
@@ -204,10 +196,7 @@ class NodeManager {
}
const ydoc = new YDoc(nodeRow.id, nodeRow.state);
ydoc.updateAttributes(
registry[updatedAttributes.type].schema,
updatedAttributes
);
ydoc.updateAttributes(updatedAttributes);
const updates = ydoc.getEncodedUpdates();
if (updates.length === 0) {
@@ -283,11 +272,7 @@ class NodeManager {
throw new Error('Node not found');
}
const model = registry[node.type];
if (!model) {
throw new Error('Invalid node type');
}
const model = registry.getModel(node.type);
const context = new NodeMutationContext(
workspace.account_id,
workspace.workspace_id,

View File

@@ -1,6 +1,11 @@
import { ServerNode } from '@/types/nodes';
export type WorkspaceRole = 'admin' | 'editor' | 'collaborator' | 'viewer';
export type WorkspaceRole =
| 'owner'
| 'admin'
| 'editor'
| 'collaborator'
| 'viewer';
export type Workspace = {
id: string;
@@ -26,7 +31,6 @@ export type WorkspaceUserOutput = {
id: string;
accountId: string;
role: WorkspaceRole;
node: ServerNode;
};
export type WorkspaceUsersInviteOutput = {

View File

@@ -109,6 +109,7 @@ const createNodesTable: Migration = {
.stored()
.references('nodes.id')
.onDelete('cascade')
.notNull()
)
.addColumn('index', 'varchar(30)', (col) =>
col.generatedAlwaysAs(sql`(attributes->>'index')::VARCHAR(30)`).stored()
@@ -158,7 +159,7 @@ const createNodePathsTable: Migration = {
INSERT INTO node_paths (ancestor_id, descendant_id, workspace_id, level)
SELECT ancestor_id, NEW.id, NEW.workspace_id, level + 1
FROM node_paths
WHERE descendant_id = NEW.parent_id;
WHERE descendant_id = NEW.parent_id AND ancestor_id <> NEW.id;
RETURN NEW;
END;
@@ -179,7 +180,7 @@ const createNodePathsTable: Migration = {
INSERT INTO node_paths (ancestor_id, descendant_id, workspace_id, level)
SELECT ancestor_id, NEW.id, NEW.workspace_id, level + 1
FROM node_paths
WHERE descendant_id = NEW.parent_id;
WHERE descendant_id = NEW.parent_id AND ancestor_id <> NEW.id;
END IF;
RETURN NEW;

View File

@@ -5,24 +5,38 @@ import {
WorkspaceStatus,
WorkspaceUserStatus,
} from '@/types/workspaces';
import { generateId, IdType, NodeRoles } from '@colanode/core';
import * as Y from 'yjs';
import { fromUint8Array } from 'js-base64';
import {
ChannelAttributes,
generateId,
IdType,
NodeRoles,
PageAttributes,
SpaceAttributes,
UserAttributes,
WorkspaceAttributes,
} from '@colanode/core';
import { NodeCreatedEvent } from '@/types/events';
import { enqueueEvent } from '@/queues/events';
import {} from './constants';
import { YDoc } from '@colanode/crdt';
export const createDefaultWorkspace = async (account: SelectAccount) => {
const createdAt = new Date();
const workspaceId = generateId(IdType.Workspace);
const workspaceName = `${account.name}'s Workspace`;
const workspaceVersionId = generateId(IdType.Version);
const user = buildUserNodeCreate(workspaceId, account);
const workspace = buildWorkspaceNodeCreate(
workspaceId,
workspaceName,
workspaceVersionId,
user.id
);
const space = buildSpaceNodeCreate(workspaceId, user.id);
const page = buildPageNodeCreate(workspaceId, space.id, user.id);
const channel = buildChannelNodeCreate(workspaceId, space.id, user.id);
const nodesToCreate = [user, space, page, channel];
const nodesToCreate = [workspace, user, space, page, channel];
await database.transaction().execute(async (trx) => {
await trx
@@ -62,26 +76,54 @@ export const createDefaultWorkspace = async (account: SelectAccount) => {
}
};
const buildWorkspaceNodeCreate = (
workspaceId: string,
workspaceName: string,
workspaceVersionId: string,
userId: string
): CreateNode => {
const attributes: WorkspaceAttributes = {
type: 'workspace',
name: workspaceName,
parentId: workspaceId,
};
const ydoc = new YDoc(workspaceId);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id: workspaceId,
workspace_id: workspaceId,
created_at: new Date(),
created_by: userId,
version_id: workspaceVersionId,
server_created_at: new Date(),
attributes: JSON.stringify(attributes),
state,
};
};
const buildUserNodeCreate = (
workspaceId: string,
account: SelectAccount
): CreateNode => {
const id = generateId(IdType.User);
const versionId = generateId(IdType.Version);
const doc = new Y.Doc({ guid: id });
const attributesMap = doc.getMap('attributes');
doc.transact(() => {
attributesMap.set('type', 'user');
attributesMap.set('name', account.name);
attributesMap.set('avatar', account.avatar);
attributesMap.set('email', account.email);
attributesMap.set('role', WorkspaceRole.Owner);
attributesMap.set('accountId', account.id);
});
const attributes: UserAttributes = {
type: 'user',
name: account.name,
avatar: account.avatar,
email: account.email,
role: 'owner',
accountId: account.id,
parentId: workspaceId,
};
const attributes = JSON.stringify(attributesMap.toJSON());
const state = Y.encodeStateAsUpdate(doc);
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
@@ -90,7 +132,7 @@ const buildUserNodeCreate = (
created_by: account.id,
version_id: versionId,
server_created_at: new Date(),
attributes,
attributes: JSON.stringify(attributes),
state,
};
};
@@ -101,24 +143,20 @@ const buildSpaceNodeCreate = (
): CreateNode => {
const id = generateId(IdType.Space);
const versionId = generateId(IdType.Version);
const doc = new Y.Doc({ guid: id });
const attributesMap = doc.getMap('attributes');
doc.transact(() => {
attributesMap.set('type', 'space');
attributesMap.set('name', 'Home');
attributesMap.set('description', 'Home space');
const attributes: SpaceAttributes = {
type: 'space',
name: 'Home',
description: 'Home space',
parentId: workspaceId,
collaborators: {
[userId]: NodeRoles.Admin,
},
};
attributesMap.set('collaborators', new Y.Map());
const collaboratorsMap = attributesMap.get(
'collaborators'
) as Y.Map<string>;
collaboratorsMap.set(userId, NodeRoles.Admin);
});
const attributes = JSON.stringify(attributesMap.toJSON());
const state = Y.encodeStateAsUpdate(doc);
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
@@ -127,7 +165,7 @@ const buildSpaceNodeCreate = (
created_by: userId,
version_id: versionId,
server_created_at: new Date(),
attributes,
attributes: JSON.stringify(attributes),
state,
};
};
@@ -139,17 +177,17 @@ const buildPageNodeCreate = (
): CreateNode => {
const id = generateId(IdType.Page);
const versionId = generateId(IdType.Version);
const doc = new Y.Doc({ guid: id });
const attributesMap = doc.getMap('attributes');
doc.transact(() => {
attributesMap.set('type', 'page');
attributesMap.set('name', 'Notes');
attributesMap.set('parentId', spaceId);
});
const attributes: PageAttributes = {
type: 'page',
name: 'Notes',
parentId: spaceId,
content: {},
};
const attributes = JSON.stringify(attributesMap.toJSON());
const state = Y.encodeStateAsUpdate(doc);
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
@@ -158,7 +196,7 @@ const buildPageNodeCreate = (
created_by: userId,
version_id: versionId,
server_created_at: new Date(),
attributes,
attributes: JSON.stringify(attributes),
state,
};
};
@@ -170,17 +208,17 @@ const buildChannelNodeCreate = (
): CreateNode => {
const id = generateId(IdType.Channel);
const versionId = generateId(IdType.Version);
const doc = new Y.Doc({ guid: id });
const attributesMap = doc.getMap('attributes');
doc.transact(() => {
attributesMap.set('type', 'channel');
attributesMap.set('parentId', spaceId);
attributesMap.set('name', 'Discussions');
});
const attributes: ChannelAttributes = {
type: 'channel',
name: 'Discussions',
parentId: spaceId,
index: '0',
};
const attributes = JSON.stringify(attributesMap.toJSON());
const state = Y.encodeStateAsUpdate(doc);
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
@@ -189,7 +227,7 @@ const buildChannelNodeCreate = (
created_by: userId,
version_id: versionId,
server_created_at: new Date(),
attributes,
attributes: JSON.stringify(attributes),
state,
};
};

View File

@@ -99,7 +99,12 @@ const handleNodeDeletedEvent = async (
const createUserNodes = async (event: NodeCreatedEvent): Promise<void> => {
const userNodesToCreate: CreateUserNode[] = [];
if (event.attributes.type === NodeTypes.User) {
const isForEveryone =
event.attributes.type === NodeTypes.User ||
event.attributes.type === NodeTypes.Workspace;
if (isForEveryone) {
const userIds = await fetchWorkspaceUsers(event.workspaceId);
for (const userId of userIds) {

View File

@@ -11,21 +11,20 @@ import {
} from '@/types/accounts';
import axios from 'axios';
import { ApiError, ColanodeRequest, ColanodeResponse } from '@/types/api';
import { generateId, IdType, NodeAttributes } from '@colanode/core';
import { generateId, IdType } from '@colanode/core';
import { database } from '@/data/database';
import bcrypt from 'bcrypt';
import { WorkspaceOutput, WorkspaceRole } from '@/types/workspaces';
import { authMiddleware } from '@/middlewares/auth';
import { generateToken } from '@/lib/tokens';
import { mapServerNode } from '@/lib/nodes';
import { enqueueTask } from '@/queues/tasks';
import * as Y from 'yjs';
import { CompiledQuery } from 'kysely';
import { NodeUpdatedEvent } from '@/types/events';
import { enqueueEvent } from '@/queues/events';
import { SelectAccount } from '@/data/schema';
import { createDefaultWorkspace } from '@/lib/workspaces';
import { sha256 } from 'js-sha256';
import { YDoc } from '@colanode/crdt';
const GoogleUserInfoUrl = 'https://www.googleapis.com/oauth2/v1/userinfo';
const SaltRounds = 10;
@@ -321,21 +320,15 @@ accountsRouter.put(
continue;
}
const doc = new Y.Doc({ guid: user.id });
Y.applyUpdate(doc, user.state);
const ydoc = new YDoc(user.id, user.state);
ydoc.updateAttributes({
...user.attributes,
name: input.name,
avatar: input.avatar ?? null,
});
const attributesMap = doc.getMap('attributes');
if (name != input.name) {
attributesMap.set('name', input.name);
}
if (avatar != input.avatar) {
attributesMap.set('avatar', input.avatar);
}
const attributes = attributesMap.toJSON() as NodeAttributes;
const attributesJson = JSON.stringify(attributes);
const state = Y.encodeStateAsUpdate(doc);
const attributes = ydoc.getAttributes();
const state = ydoc.getState();
const updatedAt = new Date();
const versionId = generateId(IdType.Version);
@@ -344,7 +337,7 @@ accountsRouter.put(
database
.updateTable('nodes')
.set({
attributes: attributesJson,
attributes: JSON.stringify(attributes),
state: state,
updated_at: updatedAt,
updated_by: user.id,
@@ -418,13 +411,6 @@ const buildLoginOutput = async (
.selectAll()
.execute();
const userIds = workspaceUsers.map((wu) => wu.id);
const userNodes = await database
.selectFrom('nodes')
.selectAll()
.where('id', 'in', userIds)
.execute();
for (const workspaceUser of workspaceUsers) {
const workspace = workspaces.find(
(w) => w.id === workspaceUser.workspace_id
@@ -434,11 +420,6 @@ const buildLoginOutput = async (
continue;
}
const userNode = userNodes.find((n) => n.id === workspaceUser.id);
if (!userNode) {
continue;
}
workspaceOutputs.push({
id: workspace.id,
name: workspace.name,
@@ -449,7 +430,6 @@ const buildLoginOutput = async (
id: workspaceUser.id,
accountId: workspaceUser.account_id,
role: workspaceUser.role as WorkspaceRole,
node: mapServerNode(userNode),
},
});
}

View File

@@ -118,13 +118,7 @@ const handleCreateNodeChange = async (
const ydoc = new YDoc(changeData.id, changeData.state);
const attributes = ydoc.getAttributes();
const model = registry[attributes.type];
if (!model) {
return {
status: 'error',
};
}
const model = registry.getModel(attributes.type);
if (!model.schema.safeParse(attributes).success) {
return {
status: 'error',
@@ -212,13 +206,7 @@ const handleUpdateNodeChange = async (
const attributesJson = JSON.stringify(attributes);
const state = ydoc.getState();
const model = registry[attributes.type];
if (!model) {
return {
status: 'error',
};
}
const model = registry.getModel(attributes.type);
if (!model.schema.safeParse(attributes).success) {
return {
status: 'error',
@@ -309,13 +297,7 @@ const handleDeleteNodeChange = async (
};
}
const model = registry[existingNode.type];
if (!model) {
return {
status: 'error',
};
}
const model = registry.getModel(existingNode.type);
const ancestorRows = await fetchNodeAncestors(existingNode.id);
const ancestors = ancestorRows.map(mapNode);
const node = ancestors.find((ancestor) => ancestor.id === existingNode.id);

View File

@@ -8,11 +8,14 @@ import {
WorkspaceStatus,
} from '@/types/workspaces';
import { ApiError, ColanodeRequest, ColanodeResponse } from '@/types/api';
import { generateId, IdType } from '@colanode/core';
import {
generateId,
IdType,
UserAttributes,
WorkspaceAttributes,
} from '@colanode/core';
import { database } from '@/data/database';
import { Router } from 'express';
import * as Y from 'yjs';
import { fromUint8Array, toUint8Array } from 'js-base64';
import {
CreateAccount,
CreateNode,
@@ -26,6 +29,7 @@ import { ServerNode } from '@/types/nodes';
import { mapServerNode } from '@/lib/nodes';
import { NodeCreatedEvent } from '@/types/events';
import { enqueueEvent } from '@/queues/events';
import { YDoc } from '@colanode/crdt';
export const workspacesRouter = Router();
@@ -62,25 +66,36 @@ workspacesRouter.post(
}
const createdAt = new Date();
const workspaceId = generateId(IdType.Workspace);
const workspaceVersionId = generateId(IdType.Version);
const workspaceDoc = new YDoc(workspaceId);
const workspaceAttributes: WorkspaceAttributes = {
type: 'workspace',
name: input.name,
description: input.description,
avatar: input.avatar,
parentId: workspaceId,
};
workspaceDoc.updateAttributes(workspaceAttributes);
const userId = generateId(IdType.User);
const userVersionId = generateId(IdType.Version);
const userDoc = new Y.Doc({ guid: userId });
const userDoc = new YDoc(userId);
const userAttributesMap = userDoc.getMap('attributes');
userDoc.transact(() => {
userAttributesMap.set('type', 'user');
userAttributesMap.set('name', account.name);
userAttributesMap.set('avatar', account.avatar);
userAttributesMap.set('email', account.email);
userAttributesMap.set('role', WorkspaceRole.Owner);
userAttributesMap.set('accountId', account.id);
});
const userAttributes: UserAttributes = {
type: 'user',
name: account.name,
avatar: account.avatar,
email: account.email,
accountId: account.id,
role: WorkspaceRole.Owner,
parentId: workspaceId,
};
const userAttributes = JSON.stringify(userAttributesMap.toJSON());
const userState = Y.encodeStateAsUpdate(userDoc);
userDoc.updateAttributes(userAttributes);
await database.transaction().execute(async (trx) => {
await trx
@@ -111,13 +126,27 @@ workspacesRouter.post(
})
.execute();
await trx
.insertInto('nodes')
.values({
id: workspaceId,
workspace_id: workspaceId,
attributes: JSON.stringify(workspaceAttributes),
state: workspaceDoc.getState(),
created_at: createdAt,
created_by: account.id,
version_id: workspaceVersionId,
server_created_at: createdAt,
})
.execute();
await trx
.insertInto('nodes')
.values({
id: userId,
workspace_id: workspaceId,
attributes: userAttributes,
state: userState,
attributes: JSON.stringify(userAttributes),
state: userDoc.getState(),
created_at: createdAt,
created_by: account.id,
version_id: userVersionId,
@@ -126,11 +155,24 @@ workspacesRouter.post(
.execute();
});
const workspaceEvent: NodeCreatedEvent = {
type: 'node_created',
id: workspaceId,
workspaceId: workspaceId,
attributes: workspaceAttributes,
createdBy: account.id,
createdAt: createdAt.toISOString(),
versionId: workspaceVersionId,
serverCreatedAt: createdAt.toISOString(),
};
await enqueueEvent(workspaceEvent);
const userEvent: NodeCreatedEvent = {
type: 'node_created',
id: userId,
workspaceId: workspaceId,
attributes: JSON.parse(userAttributes),
attributes: userAttributes,
createdBy: account.id,
createdAt: createdAt.toISOString(),
versionId: userVersionId,
@@ -149,18 +191,6 @@ workspacesRouter.post(
id: userId,
accountId: account.id,
role: WorkspaceRole.Owner,
node: {
id: userId,
workspaceId: workspaceId,
type: 'user',
attributes: JSON.parse(userAttributes),
state: fromUint8Array(userState),
createdAt: new Date(),
createdBy: account.id,
versionId: userVersionId,
serverCreatedAt: new Date(),
index: null,
},
},
};
@@ -215,33 +245,6 @@ workspacesRouter.put(
});
}
if (!workspaceUser) {
return res.status(403).json({
code: ApiError.Forbidden,
message: 'Forbidden.',
});
}
if (workspaceUser.role !== WorkspaceRole.Owner) {
return res.status(403).json({
code: ApiError.Forbidden,
message: 'Forbidden.',
});
}
const userNode = await database
.selectFrom('nodes')
.selectAll()
.where('id', '=', workspaceUser.id)
.executeTakeFirst();
if (!userNode) {
return res.status(500).json({
code: ApiError.InternalServerError,
message: 'Internal server error.',
});
}
const updatedWorkspace = await database
.updateTable('workspaces')
.set({
@@ -272,7 +275,6 @@ workspacesRouter.put(
id: workspaceUser.id,
accountId: workspaceUser.account_id,
role: workspaceUser.role,
node: mapServerNode(userNode),
},
};
@@ -373,19 +375,6 @@ workspacesRouter.get(
});
}
const userNode = await database
.selectFrom('nodes')
.selectAll()
.where('id', '=', workspaceUser.id)
.executeTakeFirst();
if (!userNode) {
return res.status(500).json({
code: ApiError.InternalServerError,
message: 'Internal server error.',
});
}
const output: WorkspaceOutput = {
id: workspace.id,
name: workspace.name,
@@ -396,7 +385,6 @@ workspacesRouter.get(
id: workspaceUser.id,
accountId: workspaceUser.account_id,
role: workspaceUser.role as WorkspaceRole,
node: mapServerNode(userNode),
},
};
@@ -427,18 +415,7 @@ workspacesRouter.get(
.where('id', 'in', workspaceIds)
.execute();
const userNodes = await database
.selectFrom('nodes')
.selectAll()
.where(
'id',
'in',
workspaceUsers.map((wa) => wa.id)
)
.execute();
const outputs: WorkspaceOutput[] = [];
for (const workspace of workspaces) {
const workspaceUser = workspaceUsers.find(
(wa) => wa.workspace_id === workspace.id
@@ -448,12 +425,6 @@ workspacesRouter.get(
continue;
}
const userNode = userNodes.find((un) => un.id === workspaceUser.id);
if (!userNode) {
continue;
}
const output: WorkspaceOutput = {
id: workspace.id,
name: workspace.name,
@@ -464,7 +435,6 @@ workspacesRouter.get(
id: workspaceUser.id,
accountId: workspaceUser.account_id,
role: workspaceUser.role as WorkspaceRole,
node: mapServerNode(userNode),
},
};
@@ -616,20 +586,19 @@ workspacesRouter.post(
const userId = generateId(IdType.User);
const userVersionId = generateId(IdType.Version);
const userDoc = new Y.Doc({ guid: userId });
const userDoc = new YDoc(userId);
const userAttributesMap = userDoc.getMap('attributes');
userDoc.transact(() => {
userAttributesMap.set('type', 'user');
userAttributesMap.set('name', account!.name);
userAttributesMap.set('avatar', account!.avatar);
userAttributesMap.set('email', account!.email);
userAttributesMap.set('role', WorkspaceRole.Collaborator);
userAttributesMap.set('accountId', account!.id);
});
const userAttributes: UserAttributes = {
type: 'user',
name: account!.name,
avatar: account!.avatar,
email: account!.email,
role: WorkspaceRole.Collaborator,
accountId: account!.id,
parentId: workspace.id,
};
const userAttributes = JSON.stringify(userAttributesMap.toJSON());
const userState = Y.encodeStateAsUpdate(userDoc);
userDoc.updateAttributes(userAttributes);
workspaceUsersToCreate.push({
id: userId,
@@ -639,14 +608,14 @@ workspacesRouter.post(
created_at: new Date(),
created_by: req.account.id,
status: WorkspaceUserStatus.Active,
version_id: generateId(IdType.Version),
version_id: userVersionId,
});
const user: ServerNode = {
id: userId,
type: 'user',
attributes: JSON.parse(userAttributes),
state: fromUint8Array(userState),
attributes: userAttributes,
state: userDoc.getEncodedState(),
createdAt: new Date(),
createdBy: workspaceUser.id,
serverCreatedAt: new Date(),
@@ -657,8 +626,8 @@ workspacesRouter.post(
usersToCreate.push({
id: user.id,
attributes: userAttributes,
state: userState,
attributes: JSON.stringify(userAttributes),
state: userDoc.getState(),
created_at: user.createdAt,
created_by: user.createdBy,
server_created_at: user.serverCreatedAt,
@@ -790,20 +759,20 @@ workspacesRouter.put(
});
}
const userDoc = new Y.Doc({ guid: user.id });
Y.applyUpdate(userDoc, user.state);
const attributes = user.attributes;
if (attributes.type !== 'user') {
return res.status(400).json({
code: ApiError.BadRequest,
message: 'BadRequest.',
});
}
const userUpdates: string[] = [];
userDoc.on('update', (update) => {
userUpdates.push(fromUint8Array(update));
});
const userAttributesMap = userDoc.getMap('attributes');
userAttributesMap.set('role', input.role);
const userAttributes = JSON.stringify(userAttributesMap.toJSON());
const state = Y.encodeStateAsUpdate(userDoc);
const updatedAt = new Date();
const userDoc = new YDoc(user.id, user.state);
userDoc.updateAttributes({
...attributes,
role: input.role,
});
const userNode: ServerNode = {
id: user.id,
@@ -811,8 +780,8 @@ workspacesRouter.put(
workspaceId: user.workspace_id,
index: null,
parentId: null,
attributes: JSON.parse(userAttributes),
state: fromUint8Array(state),
attributes: userDoc.getAttributes(),
state: userDoc.getEncodedState(),
createdAt: user.created_at,
createdBy: user.created_by,
serverCreatedAt: user.server_created_at,
@@ -837,8 +806,8 @@ workspacesRouter.put(
await trx
.updateTable('nodes')
.set({
attributes: userAttributes,
state: state,
attributes: JSON.stringify(userDoc.getAttributes()),
state: userDoc.getState(),
server_updated_at: updatedAt,
updated_at: updatedAt,
updated_by: currentWorkspaceUser.id,

View File

@@ -1,5 +1,3 @@
import { ServerNode } from '@/types/nodes';
export enum WorkspaceStatus {
Active = 1,
Inactive = 2,
@@ -49,7 +47,6 @@ export type WorkspaceUserOutput = {
id: string;
accountId: string;
role: WorkspaceRole;
node: ServerNode;
};
export type WorkspaceAccountsInviteInput = {

View File

@@ -9,7 +9,7 @@ export default defineConfig({
* The common package is using the internal packages approach, so it needs to
* be transpiled / bundled together with the deployed code.
*/
noExternal: ['@colanode/core'],
noExternal: ['@colanode/core', '@colanode/crdt'],
/**
* Do not use tsup for generating d.ts files because it can not generate type
* the definition maps required for go-to-definition to work in our IDE. We

View File

@@ -16,3 +16,4 @@ export * from './registry/record';
export * from './registry/space';
export * from './registry/user';
export * from './registry/zod';
export * from './registry/workspace';

View File

@@ -1,6 +1,7 @@
export const NodeTypes = {
User: 'user',
Space: 'space',
Workspace: 'workspace',
Page: 'page',
Channel: 'channel',
Chat: 'chat',

View File

@@ -9,23 +9,11 @@ import { DatabaseAttributes, databaseModel } from './database';
import { FileAttributes, fileModel } from './file';
import { FolderAttributes, folderModel } from './folder';
import { RecordAttributes, recordModel } from './record';
export const registry: Record<string, NodeModel> = {
channel: channelModel,
chat: chatModel,
database: databaseModel,
file: fileModel,
folder: folderModel,
message: messageModel,
page: pageModel,
record: recordModel,
space: spaceModel,
user: userModel,
};
import { WorkspaceAttributes, workspaceModel } from './workspace';
type NodeBase = {
id: string;
parentId: string | null;
parentId: string;
index: string | null;
createdAt: string;
createdBy: string;
@@ -87,6 +75,11 @@ export type UserNode = NodeBase & {
attributes: UserAttributes;
};
export type WorkspaceNode = NodeBase & {
type: 'workspace';
attributes: WorkspaceAttributes;
};
export type NodeType =
| 'channel'
| 'chat'
@@ -97,7 +90,8 @@ export type NodeType =
| 'page'
| 'record'
| 'space'
| 'user';
| 'user'
| 'workspace';
export type Node =
| ChannelNode
@@ -109,7 +103,8 @@ export type Node =
| PageNode
| RecordNode
| SpaceNode
| UserNode;
| UserNode
| WorkspaceNode;
export type NodeAttributes =
| UserAttributes
@@ -121,4 +116,34 @@ export type NodeAttributes =
| FolderAttributes
| MessageAttributes
| PageAttributes
| RecordAttributes;
| RecordAttributes
| WorkspaceAttributes;
class Registry {
private models: Map<string, NodeModel> = new Map();
constructor() {
this.models.set('channel', channelModel);
this.models.set('chat', chatModel);
this.models.set('database', databaseModel);
this.models.set('file', fileModel);
this.models.set('folder', folderModel);
this.models.set('message', messageModel);
this.models.set('page', pageModel);
this.models.set('record', recordModel);
this.models.set('space', spaceModel);
this.models.set('user', userModel);
this.models.set('workspace', workspaceModel);
}
getModel(type: string): NodeModel {
const model = this.models.get(type);
if (!model) {
throw new Error(`Model for type ${type} not found`);
}
return model;
}
}
export const registry = new Registry();

View File

@@ -5,7 +5,7 @@ import { isEqual } from 'lodash-es';
export const messageAttributesSchema = z.object({
type: z.literal('message'),
parentId: z.string().nullable(),
parentId: z.string(),
content: z.record(z.string(), blockSchema),
reactions: z.record(z.string(), z.array(z.string())),
});

View File

@@ -8,7 +8,7 @@ export const userAttributesSchema = z.object({
email: z.string().email(),
avatar: z.string().nullable(),
accountId: z.string(),
role: z.enum(['admin', 'editor', 'collaborator', 'viewer']),
role: z.enum(['owner', 'admin', 'editor', 'collaborator', 'viewer']),
});
export type UserAttributes = z.infer<typeof userAttributesSchema>;

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
import { NodeModel } from './core';
export const workspaceAttributesSchema = z.object({
type: z.literal('workspace'),
name: z.string(),
description: z.string().nullable().optional(),
avatar: z.string().nullable().optional(),
parentId: z.string(),
});
export type WorkspaceAttributes = z.infer<typeof workspaceAttributesSchema>;
export const workspaceModel: NodeModel = {
type: 'workspace',
schema: workspaceAttributesSchema,
canCreate: async (_, __) => {
return true;
},
canUpdate: async (_, __, ___) => {
return true;
},
canDelete: async (_, __) => {
return true;
},
};

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import * as Y from 'yjs';
import { NodeAttributes, ZodText } from '@colanode/core';
import { NodeAttributes, registry, ZodText } from '@colanode/core';
import { isEqual } from 'lodash-es';
import { diffChars } from 'diff';
import { fromUint8Array, toUint8Array } from 'js-base64';
@@ -23,10 +23,10 @@ export class YDoc {
});
}
public updateAttributes(
schema: z.ZodSchema,
attributes: z.infer<typeof schema>
) {
public updateAttributes(attributes: NodeAttributes) {
const model = registry.getModel(attributes.type);
const schema = model.schema;
if (!(schema instanceof z.ZodObject)) {
throw new Error('Schema must be a ZodObject');
}