New version of syncing

This commit is contained in:
Hakan Shehu
2024-11-26 22:21:35 +01:00
parent fd9b945dc5
commit 99b94f6f33
75 changed files with 3340 additions and 3480 deletions

View File

@@ -9,6 +9,7 @@ import { notificationService } from '@/main/services/notification-service';
import { fileService } from '@/main/services/file-service';
import { logService } from '@/main/services/log-service';
// one minute
const EVENT_LOOP_INTERVAL = 1000 * 60;
class Bootstrapper {

View File

@@ -16,14 +16,6 @@ const createServersTable: Migration = {
await db
.insertInto('servers')
.values([
{
domain: 'localhost:3000',
name: 'Localhost',
avatar: '',
attributes: '{ "insecure": true }',
version: '0.1.0',
created_at: new Date().toISOString(),
},
{
domain: 'eu.colanode.com',
name: 'Colanode Cloud (EU)',
@@ -88,6 +80,28 @@ const createWorkspacesTable: Migration = {
},
};
const createWorkspaceCursorsTable: Migration = {
up: async (db) => {
await db.schema
.createTable('workspace_cursors')
.addColumn('user_id', 'text', (col) =>
col
.notNull()
.primaryKey()
.references('workspaces.user_id')
.onDelete('cascade')
)
.addColumn('node_transactions', 'integer', (col) => col.notNull())
.addColumn('collaborations', 'integer', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('updated_at', 'text')
.execute();
},
down: async (db) => {
await db.schema.dropTable('workspace_cursors').execute();
},
};
const createDeletedTokensTable: Migration = {
up: async (db) => {
await db.schema
@@ -107,5 +121,6 @@ export const appDatabaseMigrations: Record<string, Migration> = {
'00001_create_servers_table': createServersTable,
'00002_create_accounts_table': createAccountsTable,
'00003_create_workspaces_table': createWorkspacesTable,
'00004_create_deleted_tokens_table': createDeletedTokensTable,
'00004_create_workspace_cursors_table': createWorkspaceCursorsTable,
'00005_create_deleted_tokens_table': createDeletedTokensTable,
};

View File

@@ -45,6 +45,18 @@ export type SelectWorkspace = Selectable<WorkspaceTable>;
export type CreateWorkspace = Insertable<WorkspaceTable>;
export type UpdateWorkspace = Updateable<WorkspaceTable>;
interface WorkspaceCursorTable {
user_id: ColumnType<string, string, never>;
node_transactions: ColumnType<bigint, bigint, bigint>;
collaborations: ColumnType<bigint, bigint, bigint>;
created_at: ColumnType<string, string, string>;
updated_at: ColumnType<string | null, string | null, string>;
}
export type SelectWorkspaceCursor = Selectable<WorkspaceCursorTable>;
export type CreateWorkspaceCursor = Insertable<WorkspaceCursorTable>;
export type UpdateWorkspaceCursor = Updateable<WorkspaceCursorTable>;
interface DeletedTokenTable {
token: ColumnType<string, string, never>;
account_id: ColumnType<string, string, never>;
@@ -56,5 +68,6 @@ export interface AppDatabaseSchema {
servers: ServerTable;
accounts: AccountTable;
workspaces: WorkspaceTable;
workspace_cursors: WorkspaceCursorTable;
deleted_tokens: DeletedTokenTable;
}

View File

@@ -15,20 +15,14 @@ const createNodesTable: Migration = {
col
.generatedAlwaysAs(sql`json_extract(attributes, '$.parentId')`)
.stored()
.references('nodes.id')
.onDelete('cascade')
.notNull()
)
.addColumn('attributes', 'text', (col) => col.notNull())
.addColumn('state', 'blob', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('updated_at', 'text')
.addColumn('created_by', 'text', (col) => col.notNull())
.addColumn('updated_by', 'text')
.addColumn('version_id', 'text', (col) => col.notNull())
.addColumn('server_created_at', 'text')
.addColumn('server_updated_at', 'text')
.addColumn('server_version_id', 'text')
.addColumn('transaction_id', 'text', (col) => col.notNull())
.execute();
await sql`
@@ -40,43 +34,49 @@ const createNodesTable: Migration = {
},
};
const createUserNodesTable: Migration = {
const createNodeTransactionsTable: Migration = {
up: async (db) => {
await db.schema
.createTable('user_nodes')
.addColumn('user_id', 'text', (col) => col.notNull())
.addColumn('node_id', 'text', (col) =>
col.notNull().references('nodes.id').onDelete('cascade')
)
.addColumn('last_seen_version_id', 'text')
.addColumn('last_seen_at', 'text')
.addColumn('mentions_count', 'integer', (col) =>
col.notNull().defaultTo(0)
)
.addColumn('attributes', 'text')
.addColumn('version_id', 'text', (col) => col.notNull())
.createTable('node_transactions')
.addColumn('id', 'text', (col) => col.notNull().primaryKey())
.addColumn('node_id', 'text', (col) => col.notNull())
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('data', 'blob')
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('updated_at', 'text')
.addPrimaryKeyConstraint('user_nodes_pk', ['user_id', 'node_id'])
.addColumn('created_by', 'text', (col) => col.notNull())
.addColumn('server_created_at', 'text')
.addColumn('retry_count', 'integer', (col) => col.defaultTo(0))
.addColumn('status', 'text', (col) => col.defaultTo('pending'))
.addColumn('number', 'integer')
.execute();
},
down: async (db) => {
await db.schema.dropTable('user_nodes').execute();
await db.schema.dropTable('node_transactions').execute();
},
};
const createChangesTable: Migration = {
const createCollaborationsTable: Migration = {
up: async (db) => {
await db.schema
.createTable('changes')
.addColumn('id', 'integer', (col) => col.notNull().primaryKey())
.addColumn('data', 'text', (col) => col.notNull())
.createTable('collaborations')
.addColumn('user_id', 'text', (col) => col.notNull())
.addColumn('node_id', 'text', (col) => col.notNull())
.addColumn('type', 'text', (col) =>
col
.notNull()
.generatedAlwaysAs(sql`json_extract(attributes, '$.type')`)
.stored()
)
.addColumn('attributes', 'text', (col) => col.notNull())
.addColumn('state', 'blob', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('retry_count', 'integer', (col) => col.defaultTo(0))
.addColumn('updated_at', 'text')
.addColumn('number', 'integer')
.addPrimaryKeyConstraint('collaborations_pk', ['user_id', 'node_id'])
.execute();
},
down: async (db) => {
await db.schema.dropTable('changes').execute();
await db.schema.dropTable('collaborations').execute();
},
};
@@ -259,8 +259,8 @@ const createNodeDeleteNameTrigger: Migration = {
export const workspaceDatabaseMigrations: Record<string, Migration> = {
'00001_create_nodes_table': createNodesTable,
'00002_create_user_nodes_table': createUserNodesTable,
'00003_create_changes_table': createChangesTable,
'00002_create_node_transactions_table': createNodeTransactionsTable,
'00003_create_collaborations_table': createCollaborationsTable,
'00004_create_uploads_table': createUploadsTable,
'00005_create_downloads_table': createDownloadsTable,
'00006_create_node_paths_table': createNodePathsTable,

View File

@@ -5,15 +5,11 @@ interface NodeTable {
parent_id: ColumnType<string, never, never>;
type: ColumnType<string, never, never>;
attributes: ColumnType<string, string, string>;
state: ColumnType<Uint8Array, Uint8Array, Uint8Array>;
created_at: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string | null>;
created_by: ColumnType<string, string, never>;
updated_by: ColumnType<string | null, string | null, string | null>;
version_id: ColumnType<string, string, string>;
server_created_at: ColumnType<string | null, string | null, string | null>;
server_updated_at: ColumnType<string | null, string | null, string | null>;
server_version_id: ColumnType<string | null, string | null, string | null>;
transaction_id: ColumnType<string, string, string>;
}
export type SelectNode = Selectable<NodeTable>;
@@ -28,32 +24,37 @@ interface NodePathTable {
export type SelectNodePath = Selectable<NodePathTable>;
interface UserNodeTable {
interface NodeTransactionTable {
id: ColumnType<string, string, never>;
node_id: ColumnType<string, string, never>;
type: ColumnType<string, string, never>;
data: ColumnType<Uint8Array | null, Uint8Array | null, never>;
created_at: ColumnType<string, string, never>;
created_by: ColumnType<string, string, never>;
server_created_at: ColumnType<string | null, string | null, string | null>;
retry_count: ColumnType<number, number, number>;
status: ColumnType<string, string, string>;
number: ColumnType<bigint | null, bigint | null, bigint | null>;
}
export type SelectNodeTransaction = Selectable<NodeTransactionTable>;
export type CreateNodeTransaction = Insertable<NodeTransactionTable>;
export type UpdateNodeTransaction = Updateable<NodeTransactionTable>;
interface CollaborationTable {
user_id: ColumnType<string, string, never>;
node_id: ColumnType<string, string, never>;
last_seen_version_id: ColumnType<string | null, string | null, string | null>;
last_seen_at: ColumnType<string | null, string | null, string | null>;
mentions_count: ColumnType<number, number, number>;
attributes: ColumnType<string | null, string | null, string | null>;
version_id: ColumnType<string, string, string>;
type: ColumnType<string, never, never>;
attributes: ColumnType<string, string, string>;
state: ColumnType<Uint8Array, Uint8Array, Uint8Array>;
created_at: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string | null>;
number: ColumnType<bigint | null, bigint | null, bigint | null>;
}
export type SelectUserNode = Selectable<UserNodeTable>;
export type CreateUserNode = Insertable<UserNodeTable>;
export type UpdateUserNode = Updateable<UserNodeTable>;
interface ChangeTable {
id: ColumnType<number, never, never>;
data: ColumnType<string, string, never>;
created_at: ColumnType<string, string, never>;
retry_count: ColumnType<number, number, number>;
}
export type SelectChange = Selectable<ChangeTable>;
export type CreateChange = Insertable<ChangeTable>;
export type UpdateChange = Updateable<ChangeTable>;
export type SelectCollaboration = Selectable<CollaborationTable>;
export type CreateCollaboration = Insertable<CollaborationTable>;
export type UpdateCollaboration = Updateable<CollaborationTable>;
interface UploadTable {
node_id: ColumnType<string, string, never>;
@@ -84,9 +85,9 @@ export type UpdateDownload = Updateable<DownloadTable>;
export interface WorkspaceDatabaseSchema {
nodes: NodeTable;
node_transactions: NodeTransactionTable;
node_paths: NodePathTable;
user_nodes: UserNodeTable;
changes: ChangeTable;
collaborations: CollaborationTable;
uploads: UploadTable;
downloads: DownloadTable;
}

View File

@@ -44,8 +44,8 @@ export class ChatCreateMutationHandler
type: 'chat',
parentId: input.workspaceId,
collaborators: {
[input.userId]: NodeRoles.Collaborator,
[input.otherUserId]: NodeRoles.Collaborator,
[input.userId]: 'collaborator',
[input.otherUserId]: 'collaborator',
},
};

View File

@@ -28,14 +28,11 @@ import { WorkspaceUpdateMutationHandler } from '@/main/mutations/workspace-updat
import { DocumentSaveMutationHandler } from '@/main/mutations/document-save';
import { AvatarUploadMutationHandler } from '@/main/mutations/avatar-upload';
import { AccountLogoutMutationHandler } from '@/main/mutations/account-logout';
import { ServerNodeSyncMutationHandler } from '@/main/mutations/server-node-sync';
import { ServerNodeDeleteMutationHandler } from '@/main/mutations/server-node-delete';
import { FolderCreateMutationHandler } from '@/main/mutations/folder-create';
import { FileCreateMutationHandler } from '@/main/mutations/file-create';
import { FileDownloadMutationHandler } from '@/main/mutations/file-download';
import { SpaceUpdateMutationHandler } from '@/main/mutations/space-update';
import { AccountUpdateMutationHandler } from '@/main/mutations/account-update';
import { ServerUserNodeSyncMutationHandler } from '@/main/mutations/server-user-node-sync';
import { MarkNodeAsSeenMutationHandler } from '@/main/mutations/mark-node-as-seen';
import { ViewUpdateMutationHandler } from '@/main/mutations/view-update';
import { ViewDeleteMutationHandler } from '@/main/mutations/view-delete';
@@ -77,14 +74,11 @@ export const mutationHandlerMap: MutationHandlerMap = {
document_save: new DocumentSaveMutationHandler(),
avatar_upload: new AvatarUploadMutationHandler(),
account_logout: new AccountLogoutMutationHandler(),
server_node_sync: new ServerNodeSyncMutationHandler(),
server_node_delete: new ServerNodeDeleteMutationHandler(),
folder_create: new FolderCreateMutationHandler(),
file_create: new FileCreateMutationHandler(),
file_download: new FileDownloadMutationHandler(),
space_update: new SpaceUpdateMutationHandler(),
account_update: new AccountUpdateMutationHandler(),
server_user_node_sync: new ServerUserNodeSyncMutationHandler(),
mark_node_as_seen: new MarkNodeAsSeenMutationHandler(),
view_update: new ViewUpdateMutationHandler(),
view_delete: new ViewDeleteMutationHandler(),

View File

@@ -1,4 +1,4 @@
import { generateId, IdType, LocalUserNodeChangeData } from '@colanode/core';
import { generateId, IdType } from '@colanode/core';
import { databaseService } from '@/main/data/database-service';
import { MutationHandler } from '@/main/types';
import {
@@ -14,103 +14,103 @@ export class MarkNodeAsSeenMutationHandler
async handleMutation(
input: MarkNodeAsSeenMutationInput
): Promise<MarkNodeAsSeenMutationOutput> {
const workspaceDatabase = await databaseService.getWorkspaceDatabase(
input.userId
);
// const workspaceDatabase = await databaseService.getWorkspaceDatabase(
// input.userId
// );
const existingUserNode = await workspaceDatabase
.selectFrom('user_nodes')
.where('node_id', '=', input.nodeId)
.where('user_id', '=', input.userId)
.selectAll()
.executeTakeFirst();
// const existingUserNode = await workspaceDatabase
// .selectFrom('user_nodes')
// .where('node_id', '=', input.nodeId)
// .where('user_id', '=', input.userId)
// .selectAll()
// .executeTakeFirst();
if (
existingUserNode &&
existingUserNode.last_seen_version_id === input.versionId
) {
const lastSeenAt = existingUserNode.last_seen_at
? new Date(existingUserNode.last_seen_at)
: null;
// if has been seen in the last 10 minutes, skip it. We don't want to spam the server with seen events.
if (lastSeenAt && Date.now() - lastSeenAt.getTime() < 10 * 60 * 1000) {
return {
success: true,
};
}
}
// if (
// existingUserNode &&
// existingUserNode.last_seen_version_id === input.versionId
// ) {
// const lastSeenAt = existingUserNode.last_seen_at
// ? new Date(existingUserNode.last_seen_at)
// : null;
// // if has been seen in the last 10 minutes, skip it. We don't want to spam the server with seen events.
// if (lastSeenAt && Date.now() - lastSeenAt.getTime() < 10 * 60 * 1000) {
// return {
// success: true,
// };
// }
// }
let changeId: number | undefined;
let userNode: UserNode | undefined;
// let changeId: number | undefined;
// let userNode: UserNode | undefined;
const changeData: LocalUserNodeChangeData = {
type: 'user_node_update',
nodeId: input.nodeId,
userId: input.userId,
lastSeenVersionId: input.versionId,
lastSeenAt: new Date().toISOString(),
mentionsCount: 0,
versionId: generateId(IdType.Version),
};
// const changeData: LocalUserNodeChangeData = {
// type: 'user_node_update',
// nodeId: input.nodeId,
// userId: input.userId,
// lastSeenVersionId: input.versionId,
// lastSeenAt: new Date().toISOString(),
// mentionsCount: 0,
// versionId: generateId(IdType.Version),
// };
await workspaceDatabase.transaction().execute(async (trx) => {
const updatedUserNode = await trx
.updateTable('user_nodes')
.set({
last_seen_version_id: input.versionId,
last_seen_at: new Date().toISOString(),
mentions_count: 0,
version_id: generateId(IdType.Version),
})
.where('node_id', '=', input.nodeId)
.where('user_id', '=', input.userId)
.returningAll()
.executeTakeFirst();
// await workspaceDatabase.transaction().execute(async (trx) => {
// const updatedUserNode = await trx
// .updateTable('user_nodes')
// .set({
// last_seen_version_id: input.versionId,
// last_seen_at: new Date().toISOString(),
// mentions_count: 0,
// version_id: generateId(IdType.Version),
// })
// .where('node_id', '=', input.nodeId)
// .where('user_id', '=', input.userId)
// .returningAll()
// .executeTakeFirst();
if (updatedUserNode) {
userNode = {
userId: updatedUserNode.user_id,
nodeId: updatedUserNode.node_id,
lastSeenAt: updatedUserNode.last_seen_at,
lastSeenVersionId: updatedUserNode.last_seen_version_id,
mentionsCount: updatedUserNode.mentions_count,
attributes: updatedUserNode.attributes,
versionId: updatedUserNode.version_id,
createdAt: updatedUserNode.created_at,
updatedAt: updatedUserNode.updated_at,
};
}
// if (updatedUserNode) {
// userNode = {
// userId: updatedUserNode.user_id,
// nodeId: updatedUserNode.node_id,
// lastSeenAt: updatedUserNode.last_seen_at,
// lastSeenVersionId: updatedUserNode.last_seen_version_id,
// mentionsCount: updatedUserNode.mentions_count,
// attributes: updatedUserNode.attributes,
// versionId: updatedUserNode.version_id,
// createdAt: updatedUserNode.created_at,
// updatedAt: updatedUserNode.updated_at,
// };
// }
const createdChange = await trx
.insertInto('changes')
.values({
data: JSON.stringify(changeData),
created_at: new Date().toISOString(),
retry_count: 0,
})
.returning('id')
.executeTakeFirst();
// const createdChange = await trx
// .insertInto('changes')
// .values({
// data: JSON.stringify(changeData),
// created_at: new Date().toISOString(),
// retry_count: 0,
// })
// .returning('id')
// .executeTakeFirst();
if (createdChange) {
changeId = createdChange.id;
}
});
// if (createdChange) {
// changeId = createdChange.id;
// }
// });
if (userNode) {
eventBus.publish({
type: 'user_node_created',
userId: input.userId,
userNode,
});
}
// if (userNode) {
// eventBus.publish({
// type: 'user_node_created',
// userId: input.userId,
// userNode,
// });
// }
if (changeId) {
eventBus.publish({
type: 'change_created',
userId: input.userId,
changeId,
});
}
// if (changeId) {
// eventBus.publish({
// type: 'change_created',
// userId: input.userId,
// changeId,
// });
// }
return {
success: true,

View File

@@ -1,48 +0,0 @@
import { databaseService } from '@/main/data/database-service';
import { socketService } from '@/main/services/socket-service';
import { MutationHandler } from '@/main/types';
import {
ServerNodeDeleteMutationInput,
ServerNodeDeleteMutationOutput,
} from '@/shared/mutations/server-node-delete';
import { nodeService } from '@/main/services/node-service';
export class ServerNodeDeleteMutationHandler
implements MutationHandler<ServerNodeDeleteMutationInput>
{
public async handleMutation(
input: ServerNodeDeleteMutationInput
): Promise<ServerNodeDeleteMutationOutput> {
const workspace = await databaseService.appDatabase
.selectFrom('workspaces')
.selectAll()
.where((eb) =>
eb.and([
eb('account_id', '=', input.accountId),
eb('workspace_id', '=', input.workspaceId),
])
)
.executeTakeFirst();
if (!workspace) {
return {
success: false,
};
}
const userId = workspace.user_id;
const deleted = await nodeService.serverDelete(userId, input.id);
if (deleted) {
socketService.sendMessage(workspace.account_id, {
type: 'local_node_delete',
nodeId: input.id,
workspaceId: input.workspaceId,
});
}
return {
success: true,
};
}
}

View File

@@ -1,57 +0,0 @@
import { databaseService } from '@/main/data/database-service';
import { socketService } from '@/main/services/socket-service';
import { MutationHandler } from '@/main/types';
import {
ServerNodeSyncMutationInput,
ServerNodeSyncMutationOutput,
} from '@/shared/mutations/server-node-sync';
import { nodeService } from '@/main/services/node-service';
export class ServerNodeSyncMutationHandler
implements MutationHandler<ServerNodeSyncMutationInput>
{
public async handleMutation(
input: ServerNodeSyncMutationInput
): Promise<ServerNodeSyncMutationOutput> {
try {
const workspace = await databaseService.appDatabase
.selectFrom('workspaces')
.selectAll()
.where((eb) =>
eb.and([
eb('account_id', '=', input.accountId),
eb('workspace_id', '=', input.node.workspaceId),
])
)
.executeTakeFirst();
if (!workspace) {
return {
success: false,
};
}
const userId = workspace.user_id;
const synced = await nodeService.serverSync(userId, input.node);
if (synced) {
socketService.sendMessage(workspace.account_id, {
type: 'local_node_sync',
nodeId: input.node.id,
userId: userId,
versionId: input.node.versionId,
workspaceId: input.node.workspaceId,
});
return {
success: true,
};
}
} catch (error) {
// console.error('Error syncing node', error);
}
return {
success: false,
};
}
}

View File

@@ -1,57 +0,0 @@
import { databaseService } from '@/main/data/database-service';
import { socketService } from '@/main/services/socket-service';
import { MutationHandler } from '@/main/types';
import {
ServerUserNodeSyncMutationInput,
ServerUserNodeSyncMutationOutput,
} from '@/shared/mutations/server-user-node-sync';
import { nodeService } from '@/main/services/node-service';
export class ServerUserNodeSyncMutationHandler
implements MutationHandler<ServerUserNodeSyncMutationInput>
{
public async handleMutation(
input: ServerUserNodeSyncMutationInput
): Promise<ServerUserNodeSyncMutationOutput> {
try {
const workspace = await databaseService.appDatabase
.selectFrom('workspaces')
.selectAll()
.where((eb) =>
eb.and([
eb('account_id', '=', input.accountId),
eb('workspace_id', '=', input.userNode.workspaceId),
])
)
.executeTakeFirst();
if (!workspace) {
return {
success: false,
};
}
const userId = workspace.user_id;
const synced = await nodeService.serverUserNodeSync(
userId,
input.userNode
);
if (synced) {
socketService.sendMessage(workspace.account_id, {
type: 'local_user_node_sync',
userId: input.userNode.userId,
nodeId: input.userNode.nodeId,
versionId: input.userNode.versionId,
workspaceId: input.userNode.workspaceId,
});
}
} catch (error) {
// console.error('Error syncing user node', error);
}
return {
success: true,
};
}
}

View File

@@ -37,7 +37,7 @@ export class SpaceCreateMutationHandler
type: 'space',
name: input.name,
collaborators: {
[input.userId]: NodeRoles.Admin,
[input.userId]: 'admin',
},
parentId: input.workspaceId,
description: input.description,

View File

@@ -6,9 +6,6 @@ import {
} from '@/shared/mutations/workspace-user-role-update';
import { WorkspaceUserRoleUpdateOutput } from '@colanode/core';
import { MutationHandler } from '@/main/types';
import { toUint8Array } from 'js-base64';
import { eventBus } from '@/shared/lib/event-bus';
import { mapNode } from '@/main/utils';
export class WorkspaceUserRoleUpdateMutationHandler
implements MutationHandler<WorkspaceUserRoleUpdateMutationInput>
@@ -52,7 +49,7 @@ export class WorkspaceUserRoleUpdateMutationHandler
};
}
const { data } = await httpClient.put<WorkspaceUserRoleUpdateOutput>(
await httpClient.put<WorkspaceUserRoleUpdateOutput>(
`/v1/workspaces/${workspace.workspace_id}/users/${input.userToUpdateId}`,
{
role: input.role,
@@ -63,37 +60,6 @@ export class WorkspaceUserRoleUpdateMutationHandler
}
);
const workspaceDatabase = await databaseService.getWorkspaceDatabase(
input.userId
);
const updatedUser = await workspaceDatabase
.updateTable('nodes')
.returningAll()
.set({
attributes: JSON.stringify(data.user.attributes),
state: toUint8Array(data.user.state),
updated_at: data.user.updatedAt,
updated_by: data.user.updatedBy,
version_id: data.user.versionId,
server_updated_at: data.user.updatedAt,
server_version_id: data.user.versionId,
})
.where('id', '=', data.user.id)
.executeTakeFirst();
if (!updatedUser) {
return {
success: false,
};
}
eventBus.publish({
type: 'node_updated',
userId: input.userId,
node: mapNode(updatedUser),
});
return {
success: true,
};

View File

@@ -6,10 +6,6 @@ import {
} from '@/shared/mutations/workspace-users-invite';
import { MutationHandler } from '@/main/types';
import { WorkspaceUsersInviteOutput } from '@colanode/core';
import { CreateNode } from '@/main/data/workspace/schema';
import { toUint8Array } from 'js-base64';
import { eventBus } from '@/shared/lib/event-bus';
import { mapNode } from '@/main/utils';
export class WorkspaceUsersInviteMutationHandler
implements MutationHandler<WorkspaceUsersInviteMutationInput>
@@ -65,47 +61,6 @@ export class WorkspaceUsersInviteMutationHandler
}
);
const workspaceDatabase = await databaseService.getWorkspaceDatabase(
input.userId
);
const usersToCreate: CreateNode[] = data.users.map((user) => {
return {
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,
server_version_id: user.versionId,
version_id: user.versionId,
};
});
const createdNodes = await workspaceDatabase
.insertInto('nodes')
.returningAll()
.values(usersToCreate)
.onConflict((cb) => cb.doNothing())
.execute();
if (createdNodes.length !== usersToCreate.length) {
return {
success: false,
};
}
for (const createdNode of createdNodes) {
eventBus.publish({
type: 'node_created',
userId: input.userId,
node: mapNode(createdNode),
});
}
return {
success: true,
};

View File

@@ -0,0 +1,80 @@
import { CollaborationAttributes, ServerCollaboration } from '@colanode/core';
import { databaseService } from '@/main/data/database-service';
import { decodeState, YDoc } from '@colanode/crdt';
class CollaborationService {
public async applyServerCollaboration(
userId: string,
collaboration: ServerCollaboration
) {
if (collaboration.deletedAt) {
return this.deleteCollaboration(userId, collaboration);
}
return this.upsertCollaboration(userId, collaboration);
}
private async deleteCollaboration(
userId: string,
collaboration: ServerCollaboration
) {
const workspaceDatabase =
await databaseService.getWorkspaceDatabase(userId);
await workspaceDatabase
.deleteFrom('collaborations')
.where('user_id', '=', collaboration.userId)
.where('node_id', '=', collaboration.nodeId)
.execute();
}
private async upsertCollaboration(
userId: string,
collaboration: ServerCollaboration
) {
const workspaceDatabase =
await databaseService.getWorkspaceDatabase(userId);
const existingCollaboration = await workspaceDatabase
.selectFrom('collaborations')
.selectAll()
.where('user_id', '=', userId)
.where('node_id', '=', collaboration.nodeId)
.executeTakeFirst();
const ydoc = new YDoc();
if (existingCollaboration) {
ydoc.applyUpdate(existingCollaboration.state);
}
const state = decodeState(collaboration.state);
const number = BigInt(collaboration.number);
ydoc.applyUpdate(state);
const attributes = ydoc.getAttributes<CollaborationAttributes>();
const attributesJson = JSON.stringify(attributes);
await workspaceDatabase
.insertInto('collaborations')
.values({
node_id: collaboration.nodeId,
user_id: userId,
attributes: attributesJson,
state: state,
created_at: collaboration.createdAt,
number: number,
updated_at: collaboration.updatedAt,
})
.onConflict((b) =>
b.columns(['user_id', 'node_id']).doUpdateSet({
attributes: attributesJson,
state: state,
number: number,
updated_at: collaboration.updatedAt,
})
)
.execute();
}
}
export const collaborationService = new CollaborationService();

View File

@@ -3,20 +3,30 @@ import {
NodeAttributes,
NodeMutationContext,
registry,
LocalCreateNodeChangeData,
LocalDeleteNodeChangeData,
LocalUpdateNodeChangeData,
ServerNodeState,
ServerUserNodeState,
ServerNodeCreateTransaction,
ServerNodeDeleteTransaction,
ServerNodeTransaction,
ServerNodeUpdateTransaction,
} from '@colanode/core';
import { YDoc } from '@colanode/crdt';
import { decodeState, YDoc } from '@colanode/crdt';
import { generateId, IdType } from '@colanode/core';
import { databaseService } from '@/main/data/database-service';
import { fetchNodeAncestors, mapNode } from '@/main/utils';
import { CreateDownload, CreateUpload } from '@/main/data/workspace/schema';
import {
fetchNodeAncestors,
mapDownload,
mapNode,
mapTransaction,
mapUpload,
} from '@/main/utils';
import {
CreateDownload,
CreateUpload,
SelectDownload,
SelectNode,
SelectNodeTransaction,
SelectUpload,
} from '@/main/data/workspace/schema';
import { eventBus } from '@/shared/lib/event-bus';
import { Download } from '@/shared/types/nodes';
import { Upload } from '@/shared/types/nodes';
import { SelectWorkspace } from '@/main/data/app/schema';
export type CreateNodeInput = {
@@ -51,17 +61,17 @@ class NodeService {
const workspace = await this.fetchWorkspace(userId);
const inputs = Array.isArray(input) ? input : [input];
const createdNodes: Node[] = [];
const createdUploads: Upload[] = [];
const createdDownloads: Download[] = [];
const createdChangeIds: number[] = [];
const createdNodes: SelectNode[] = [];
const createdNodeTransactions: SelectNodeTransaction[] = [];
const createdUploads: SelectUpload[] = [];
const createdDownloads: SelectDownload[] = [];
const workspaceDatabase =
await databaseService.getWorkspaceDatabase(userId);
await workspaceDatabase.transaction().execute(async (transaction) => {
for (const inputItem of inputs) {
const model = registry.getModel(inputItem.attributes.type);
const model = registry.getNodeModel(inputItem.attributes.type);
if (!model.schema.safeParse(inputItem.attributes).success) {
throw new Error('Invalid attributes');
}
@@ -87,88 +97,80 @@ class NodeService {
throw new Error('Insufficient permissions');
}
const ydoc = new YDoc(inputItem.id);
ydoc.updateAttributes(inputItem.attributes);
const ydoc = new YDoc();
const update = ydoc.updateAttributes(
model.schema,
inputItem.attributes
);
const createdAt = new Date().toISOString();
const versionId = generateId(IdType.Version);
const transactionId = generateId(IdType.Transaction);
const changeData: LocalCreateNodeChangeData = {
type: 'node_create',
id: inputItem.id,
state: ydoc.getEncodedState(),
createdAt: createdAt,
createdBy: context.userId,
versionId: versionId,
};
const createdNodeRow = await transaction
const createdNode = await transaction
.insertInto('nodes')
.returningAll()
.values({
id: inputItem.id,
attributes: JSON.stringify(inputItem.attributes),
state: ydoc.getState(),
created_at: createdAt,
created_by: context.userId,
version_id: versionId,
transaction_id: transactionId,
})
.executeTakeFirst();
if (createdNodeRow) {
const createdNode = mapNode(createdNodeRow);
createdNodes.push(createdNode);
if (!createdNode) {
throw new Error('Failed to create node');
}
const createdChange = await transaction
.insertInto('changes')
.returning('id')
createdNodes.push(createdNode);
const createdTransaction = await transaction
.insertInto('node_transactions')
.returningAll()
.values({
data: JSON.stringify(changeData),
id: transactionId,
node_id: inputItem.id,
type: 'create',
data: update,
created_at: createdAt,
created_by: context.userId,
retry_count: 0,
status: 'pending',
})
.executeTakeFirst();
if (createdChange) {
createdChangeIds.push(createdChange.id);
if (!createdTransaction) {
throw new Error('Failed to create transaction');
}
createdNodeTransactions.push(createdTransaction);
if (inputItem.upload) {
const createdUploadRow = await transaction
const createdUpload = await transaction
.insertInto('uploads')
.returningAll()
.values(inputItem.upload)
.executeTakeFirst();
if (createdUploadRow) {
createdUploads.push({
nodeId: createdUploadRow.node_id,
createdAt: createdUploadRow.created_at,
updatedAt: createdUploadRow.updated_at,
progress: createdUploadRow.progress,
retryCount: createdUploadRow.retry_count,
});
if (!createdUpload) {
throw new Error('Failed to create upload');
}
createdUploads.push(createdUpload);
}
if (inputItem.download) {
const createdDownloadRow = await transaction
const createdDownload = await transaction
.insertInto('downloads')
.returningAll()
.values(inputItem.download)
.executeTakeFirst();
if (createdDownloadRow) {
createdDownloads.push({
nodeId: createdDownloadRow.node_id,
uploadId: createdDownloadRow.upload_id,
createdAt: createdDownloadRow.created_at,
updatedAt: createdDownloadRow.updated_at,
progress: createdDownloadRow.progress,
retryCount: createdDownloadRow.retry_count,
});
if (!createdDownload) {
throw new Error('Failed to create download');
}
createdDownloads.push(createdDownload);
}
}
});
@@ -177,7 +179,15 @@ class NodeService {
eventBus.publish({
type: 'node_created',
userId,
node: createdNode,
node: mapNode(createdNode),
});
}
for (const createdTransaction of createdNodeTransactions) {
eventBus.publish({
type: 'node_transaction_created',
userId,
transaction: mapTransaction(createdTransaction),
});
}
@@ -185,7 +195,7 @@ class NodeService {
eventBus.publish({
type: 'upload_created',
userId,
upload: createdUpload,
upload: mapUpload(createdUpload),
});
}
@@ -193,15 +203,7 @@ class NodeService {
eventBus.publish({
type: 'download_created',
userId,
download: createdDownload,
});
}
for (const createdChangeId of createdChangeIds) {
eventBus.publish({
type: 'change_created',
userId,
changeId: createdChangeId,
download: mapDownload(createdDownload),
});
}
}
@@ -253,11 +255,11 @@ class NodeService {
ancestors
);
const versionId = generateId(IdType.Version);
const transactionId = generateId(IdType.Transaction);
const updatedAt = new Date().toISOString();
const updatedAttributes = updater(node.attributes);
const model = registry.getModel(node.type);
const model = registry.getNodeModel(node.type);
if (!model.schema.safeParse(updatedAttributes).success) {
throw new Error('Invalid attributes');
}
@@ -266,23 +268,22 @@ class NodeService {
throw new Error('Insufficient permissions');
}
const ydoc = new YDoc(nodeRow.id, nodeRow.state);
ydoc.updateAttributes(updatedAttributes);
const ydoc = new YDoc();
const previousTransactions = await workspaceDatabase
.selectFrom('node_transactions')
.where('node_id', '=', nodeId)
.selectAll()
.execute();
const updates = ydoc.getEncodedUpdates();
if (updates.length === 0) {
return true;
for (const previousTransaction of previousTransactions) {
if (previousTransaction.data === null) {
throw new Error('Node has been deleted');
}
ydoc.applyUpdate(previousTransaction.data);
}
let changeId: number | undefined;
const changeData: LocalUpdateNodeChangeData = {
type: 'node_update',
id: nodeId,
updatedAt: updatedAt,
updatedBy: context.userId,
versionId: versionId,
updates: updates,
};
const update = ydoc.updateAttributes(model.schema, updatedAttributes);
const result = await workspaceDatabase
.transaction()
@@ -292,31 +293,30 @@ class NodeService {
.returningAll()
.set({
attributes: JSON.stringify(ydoc.getAttributes()),
state: ydoc.getState(),
updated_at: updatedAt,
updated_by: context.userId,
version_id: versionId,
transaction_id: transactionId,
})
.where('id', '=', nodeId)
.where('version_id', '=', node.versionId)
.where('transaction_id', '=', node.transactionId)
.executeTakeFirst();
if (updatedRow) {
node = mapNode(updatedRow);
const createdChange = await trx
.insertInto('changes')
.returning('id')
await trx
.insertInto('node_transactions')
.values({
data: JSON.stringify(changeData),
id: transactionId,
node_id: nodeId,
type: 'update',
data: update,
created_at: updatedAt,
created_by: context.userId,
retry_count: 0,
status: 'pending',
})
.executeTakeFirst();
if (createdChange) {
changeId = createdChange.id;
}
.execute();
}
return true;
@@ -330,32 +330,23 @@ class NodeService {
});
}
if (changeId) {
eventBus.publish({
type: 'change_created',
userId,
changeId,
});
}
return result;
}
public async deleteNode(nodeId: string, userId: string) {
const workspace = await this.fetchWorkspace(userId);
const workspaceDatabase =
await databaseService.getWorkspaceDatabase(userId);
const ancestorRows = await fetchNodeAncestors(workspaceDatabase, nodeId);
const ancestors = ancestorRows.map(mapNode);
const node = ancestors.find((ancestor) => ancestor.id === nodeId);
const node = ancestors.find((ancestor) => ancestor.id === nodeId);
if (!node) {
throw new Error('Node not found');
}
const model = registry.getModel(node.type);
const model = registry.getNodeModel(node.type);
const context = new NodeMutationContext(
workspace.account_id,
workspace.workspace_id,
@@ -368,30 +359,26 @@ class NodeService {
throw new Error('Insufficient permissions');
}
let changeId: number | undefined;
const changeData: LocalDeleteNodeChangeData = {
type: 'node_delete',
id: nodeId,
deletedAt: new Date().toISOString(),
deletedBy: context.userId,
};
await workspaceDatabase.transaction().execute(async (trx) => {
await trx.deleteFrom('nodes').where('id', '=', nodeId).execute();
await trx
.deleteFrom('node_transactions')
.where('node_id', '=', nodeId)
.execute();
const createdChange = await trx
.insertInto('changes')
.returning('id')
await trx
.insertInto('node_transactions')
.values({
data: JSON.stringify(changeData),
id: generateId(IdType.Transaction),
node_id: nodeId,
type: 'delete',
data: null,
created_at: new Date().toISOString(),
created_by: context.userId,
retry_count: 0,
status: 'pending',
})
.executeTakeFirst();
if (createdChange) {
changeId = createdChange.id;
}
});
eventBus.publish({
@@ -399,243 +386,233 @@ class NodeService {
userId,
node: node,
});
if (changeId) {
eventBus.publish({
type: 'change_created',
userId,
changeId,
});
}
}
public async serverSync(
public async applyServerTransaction(
userId: string,
node: ServerNodeState
): Promise<boolean> {
let count = 0;
while (count++ < 20) {
const synced = await this.tryServerSync(userId, node);
if (synced) {
return true;
}
transaction: ServerNodeTransaction
) {
if (transaction.type === 'create') {
await this.applyServerCreateTransaction(userId, transaction);
} else if (transaction.type === 'update') {
await this.applyServerUpdateTransaction(userId, transaction);
} else if (transaction.type === 'delete') {
await this.applyServerDeleteTransaction(userId, transaction);
}
return false;
}
private async tryServerSync(userId: string, node: ServerNodeState) {
private async applyServerCreateTransaction(
userId: string,
transaction: ServerNodeCreateTransaction
) {
const workspaceDatabase =
await databaseService.getWorkspaceDatabase(userId);
const existingNode = await workspaceDatabase
.selectFrom('nodes')
.where('id', '=', node.id)
.selectAll()
const number = BigInt(transaction.number);
const existingTransaction = await workspaceDatabase
.selectFrom('node_transactions')
.select(['id', 'status', 'number', 'server_created_at'])
.where('id', '=', transaction.id)
.executeTakeFirst();
if (!existingNode) {
const ydoc = new YDoc(node.id, node.state);
const attributes = ydoc.getAttributes();
const state = ydoc.getState();
const result = await workspaceDatabase
.insertInto('nodes')
.returningAll()
.values({
id: node.id,
attributes: JSON.stringify(attributes),
state: state,
created_at: node.createdAt,
created_by: node.createdBy,
version_id: node.versionId,
server_created_at: node.serverCreatedAt,
server_version_id: node.versionId,
})
.onConflict((cb) => cb.doNothing())
.executeTakeFirst();
if (result) {
eventBus.publish({
type: 'node_created',
userId: userId,
node: mapNode(result),
});
return true;
if (existingTransaction) {
if (
existingTransaction.status === 'synced' &&
existingTransaction.number === number &&
existingTransaction.server_created_at === transaction.serverCreatedAt
) {
return;
}
} else {
const ydoc = new YDoc(node.id, existingNode.state);
ydoc.applyUpdate(node.state);
const attributes = ydoc.getAttributes();
const state = ydoc.getState();
const updatedNode = await workspaceDatabase
.updateTable('nodes')
.returningAll()
await workspaceDatabase
.updateTable('node_transactions')
.set({
state: state,
attributes: JSON.stringify(attributes),
server_created_at: node.serverCreatedAt,
server_updated_at: node.serverUpdatedAt,
server_version_id: node.versionId,
updated_at: node.updatedAt,
updated_by: node.updatedBy,
version_id: node.versionId,
status: 'synced',
number,
server_created_at: transaction.serverCreatedAt,
})
.where('id', '=', node.id)
.where('version_id', '=', existingNode.version_id)
.executeTakeFirst();
.where('id', '=', transaction.id)
.execute();
if (updatedNode) {
eventBus.publish({
type: 'node_updated',
userId: userId,
node: mapNode(updatedNode),
});
return true;
}
return;
}
return false;
}
const ydoc = new YDoc();
ydoc.applyUpdate(transaction.data);
public async serverUpsert(userId: string, node: ServerNodeState) {
const workspaceDatabase =
await databaseService.getWorkspaceDatabase(userId);
const existingNode = await workspaceDatabase
.selectFrom('nodes')
.where('id', '=', node.id)
.selectAll()
.executeTakeFirst();
const ydoc = new YDoc(node.id, existingNode?.state);
const attributes = ydoc.getAttributes();
const result = await workspaceDatabase
.insertInto('nodes')
.returningAll()
.values({
id: node.id,
attributes: JSON.stringify(attributes),
state: ydoc.getState(),
created_at: node.createdAt,
created_by: node.createdBy,
version_id: node.versionId,
server_created_at: node.serverCreatedAt,
server_version_id: node.versionId,
})
.onConflict((cb) =>
cb.columns(['id']).doUpdateSet({
state: ydoc.getState(),
attributes: JSON.stringify(attributes),
updated_at: node.updatedAt,
updated_by: node.updatedBy,
version_id: node.versionId,
server_created_at: node.serverCreatedAt,
server_updated_at: node.serverUpdatedAt,
server_version_id: node.versionId,
})
)
.executeTakeFirst();
.transaction()
.execute(async (trx) => {
await trx
.insertInto('node_transactions')
.values({
id: transaction.id,
node_id: transaction.nodeId,
type: 'create',
data: decodeState(transaction.data),
created_at: transaction.createdAt,
created_by: transaction.createdBy,
retry_count: 0,
status: 'synced',
number,
server_created_at: transaction.serverCreatedAt,
})
.execute();
const nodeRow = await trx
.insertInto('nodes')
.returningAll()
.values({
id: transaction.nodeId,
attributes: JSON.stringify(attributes),
created_at: transaction.createdAt,
created_by: transaction.createdBy,
transaction_id: transaction.id,
})
.executeTakeFirst();
return nodeRow;
});
if (result) {
if (existingNode) {
eventBus.publish({
type: 'node_updated',
userId: userId,
node: mapNode(result),
});
} else {
eventBus.publish({
type: 'node_created',
userId: userId,
node: mapNode(result),
});
}
return true;
eventBus.publish({
type: 'node_created',
userId,
node: mapNode(result),
});
}
return false;
}
public async serverDelete(userId: string, nodeId: string) {
private async applyServerUpdateTransaction(
userId: string,
transaction: ServerNodeUpdateTransaction
) {
const workspaceDatabase =
await databaseService.getWorkspaceDatabase(userId);
const deletedNode = await workspaceDatabase
.deleteFrom('nodes')
.returningAll()
.where('id', '=', nodeId)
const number = BigInt(transaction.number);
const existingTransaction = await workspaceDatabase
.selectFrom('node_transactions')
.select(['id', 'status', 'number', 'server_created_at'])
.where('id', '=', transaction.id)
.executeTakeFirst();
if (deletedNode) {
if (existingTransaction) {
if (
existingTransaction.status === 'synced' &&
existingTransaction.number === number &&
existingTransaction.server_created_at === transaction.serverCreatedAt
) {
return;
}
await workspaceDatabase
.updateTable('node_transactions')
.set({
status: 'synced',
number,
server_created_at: transaction.serverCreatedAt,
})
.where('id', '=', transaction.id)
.execute();
return;
}
const previousTransactions = await workspaceDatabase
.selectFrom('node_transactions')
.selectAll()
.where('node_id', '=', transaction.nodeId)
.orderBy('id', 'asc')
.execute();
const ydoc = new YDoc();
for (const previousTransaction of previousTransactions) {
if (previousTransaction.data) {
ydoc.applyUpdate(previousTransaction.data);
}
}
ydoc.applyUpdate(transaction.data);
const attributes = ydoc.getAttributes();
const result = await workspaceDatabase
.transaction()
.execute(async (trx) => {
await trx
.insertInto('node_transactions')
.values({
id: transaction.id,
node_id: transaction.nodeId,
type: 'update',
data: decodeState(transaction.data),
created_at: transaction.createdAt,
created_by: transaction.createdBy,
retry_count: 0,
status: 'synced',
number,
server_created_at: transaction.serverCreatedAt,
})
.execute();
const nodeRow = await trx
.updateTable('nodes')
.returningAll()
.set({
attributes: JSON.stringify(attributes),
updated_at: transaction.createdAt,
updated_by: transaction.createdBy,
transaction_id: transaction.id,
})
.where('id', '=', transaction.nodeId)
.executeTakeFirst();
return nodeRow;
});
if (result) {
eventBus.publish({
type: 'node_updated',
userId,
node: mapNode(result),
});
}
}
private async applyServerDeleteTransaction(
userId: string,
transaction: ServerNodeDeleteTransaction
) {
const workspaceDatabase =
await databaseService.getWorkspaceDatabase(userId);
const result = await workspaceDatabase
.transaction()
.execute(async (trx) => {
await trx
.deleteFrom('node_transactions')
.where('node_id', '=', transaction.nodeId)
.execute();
const nodeRow = await trx
.deleteFrom('nodes')
.returningAll()
.where('id', '=', transaction.nodeId)
.executeTakeFirst();
return nodeRow;
});
if (result) {
eventBus.publish({
type: 'node_deleted',
userId,
node: mapNode(deletedNode),
node: mapNode(result),
});
}
return false;
}
public async serverUserNodeSync(
userId: string,
userNode: ServerUserNodeState
): Promise<boolean> {
const workspaceDatabase =
await databaseService.getWorkspaceDatabase(userId);
const createdUserNode = await workspaceDatabase
.insertInto('user_nodes')
.returningAll()
.values({
user_id: userNode.userId,
node_id: userNode.nodeId,
version_id: userNode.versionId,
last_seen_at: userNode.lastSeenAt,
last_seen_version_id: userNode.lastSeenVersionId,
mentions_count: userNode.mentionsCount,
created_at: userNode.createdAt,
updated_at: userNode.updatedAt,
})
.onConflict((cb) =>
cb.columns(['node_id', 'user_id']).doUpdateSet({
last_seen_at: userNode.lastSeenAt,
last_seen_version_id: userNode.lastSeenVersionId,
mentions_count: userNode.mentionsCount,
updated_at: userNode.updatedAt,
version_id: userNode.versionId,
})
)
.executeTakeFirst();
if (createdUserNode) {
eventBus.publish({
type: 'user_node_created',
userId: userId,
userNode: {
userId: createdUserNode.user_id,
nodeId: createdUserNode.node_id,
lastSeenAt: createdUserNode.last_seen_at,
lastSeenVersionId: createdUserNode.last_seen_version_id,
mentionsCount: createdUserNode.mentions_count,
attributes: createdUserNode.attributes,
versionId: createdUserNode.version_id,
createdAt: createdUserNode.created_at,
updatedAt: createdUserNode.updated_at,
},
});
return true;
}
return false;
}
async fetchWorkspace(userId: string): Promise<SelectWorkspace> {

View File

@@ -70,56 +70,56 @@ class RadarService {
nodeStates: {},
};
const nodeUnreadMessageCounts = await workspaceDatabase
.selectFrom('user_nodes as un')
.innerJoin('nodes as n', 'un.node_id', 'n.id')
.where('un.user_id', '=', userId)
.where('n.type', '=', NodeTypes.Message)
.where('un.last_seen_version_id', 'is', null)
.select(['n.parent_id as node_id'])
.select((eb) => [
eb.fn.count<number>('un.node_id').as('messages_count'),
eb.fn.sum<number>('un.mentions_count').as('mentions_count'),
])
.groupBy('n.parent_id')
.execute();
// const nodeUnreadMessageCounts = await workspaceDatabase
// .selectFrom('user_nodes as un')
// .innerJoin('nodes as n', 'un.node_id', 'n.id')
// .where('un.user_id', '=', userId)
// .where('n.type', '=', NodeTypes.Message)
// .where('un.last_seen_version_id', 'is', null)
// .select(['n.parent_id as node_id'])
// .select((eb) => [
// eb.fn.count<number>('un.node_id').as('messages_count'),
// eb.fn.sum<number>('un.mentions_count').as('mentions_count'),
// ])
// .groupBy('n.parent_id')
// .execute();
for (const nodeUnreadMessageCount of nodeUnreadMessageCounts) {
const idType = getIdType(nodeUnreadMessageCount.node_id);
const nodeId = nodeUnreadMessageCount.node_id;
const messagesCount = nodeUnreadMessageCount.messages_count;
const mentionsCount = nodeUnreadMessageCount.mentions_count;
// for (const nodeUnreadMessageCount of nodeUnreadMessageCounts) {
// const idType = getIdType(nodeUnreadMessageCount.node_id);
// const nodeId = nodeUnreadMessageCount.node_id;
// const messagesCount = nodeUnreadMessageCount.messages_count;
// const mentionsCount = nodeUnreadMessageCount.mentions_count;
if (idType === IdType.Chat) {
data.nodeStates[nodeId] = {
type: 'chat',
nodeId,
unseenMessagesCount: messagesCount,
mentionsCount,
};
// if (idType === IdType.Chat) {
// data.nodeStates[nodeId] = {
// type: 'chat',
// nodeId,
// unseenMessagesCount: messagesCount,
// mentionsCount,
// };
if (mentionsCount > 0) {
data.importantCount += mentionsCount;
}
// if (mentionsCount > 0) {
// data.importantCount += mentionsCount;
// }
if (messagesCount > 0) {
data.importantCount += messagesCount;
}
} else if (idType === IdType.Channel) {
data.nodeStates[nodeId] = {
type: 'channel',
nodeId,
unseenMessagesCount: messagesCount,
mentionsCount,
};
// if (messagesCount > 0) {
// data.importantCount += messagesCount;
// }
// } else if (idType === IdType.Channel) {
// data.nodeStates[nodeId] = {
// type: 'channel',
// nodeId,
// unseenMessagesCount: messagesCount,
// mentionsCount,
// };
if (messagesCount > 0) {
data.hasUnseenChanges = true;
} else if (mentionsCount > 0) {
data.importantCount += messagesCount;
}
}
}
// if (messagesCount > 0) {
// data.hasUnseenChanges = true;
// } else if (mentionsCount > 0) {
// data.importantCount += messagesCount;
// }
// }
// }
this.workspaceStates.set(userId, data);
}

View File

@@ -2,7 +2,7 @@ import { WebSocket } from 'ws';
import { BackoffCalculator } from '@/shared/lib/backoff-calculator';
import { Message } from '@colanode/core';
import { SelectAccount } from '@/main/data/app/schema';
import { mutationService } from '@/main/services/mutation-service';
import { syncService } from '@/main/services/sync-service';
export class SocketConnection {
private readonly synapseUrl: string;
@@ -46,25 +46,10 @@ export class SocketConnection {
return;
}
const message: Message = JSON.parse(data);
if (message.type === 'server_node_delete') {
mutationService.executeMutation({
type: 'server_node_delete',
id: message.id,
accountId: this.account.id,
workspaceId: message.workspaceId,
});
} else if (message.type === 'server_node_sync') {
mutationService.executeMutation({
type: 'server_node_sync',
accountId: this.account.id,
node: message.node,
});
} else if (message.type === 'server_user_node_sync') {
mutationService.executeMutation({
type: 'server_user_node_sync',
accountId: this.account.id,
userNode: message.userNode,
});
if (message.type === 'node_transactions_batch') {
syncService.syncServerTransactions(message);
} else if (message.type === 'collaborations_batch') {
syncService.syncServerCollaborations(message);
}
};

View File

@@ -1,17 +1,20 @@
import { databaseService } from '@/main/data/database-service';
import { mapChange } from '@/main/utils';
import { mapTransaction } from '@/main/utils';
import { eventBus } from '@/shared/lib/event-bus';
import {
LocalChange,
SyncChangesOutput,
SyncNodeStatesOutput,
} from '@colanode/core';
import { WorkspaceDatabaseSchema } from '@/main/data/workspace/schema';
import { Kysely } from 'kysely';
import { httpClient } from '@/shared/lib/http-client';
import { serverService } from '@/main/services/server-service';
import {
CollaborationsBatchMessage,
FetchCollaborationsMessage,
FetchNodeTransactionsMessage,
LocalNodeTransaction,
NodeTransactionsBatchMessage,
SyncNodeTransactionsOutput,
} from '@colanode/core';
import { logService } from '@/main/services/log-service';
import { nodeService } from '@/main/services/node-service';
import { socketService } from '@/main/services/socket-service';
import { collaborationService } from '@/main/services/collaboration-service';
type WorkspaceSyncState = {
isSyncing: boolean;
@@ -19,12 +22,18 @@ type WorkspaceSyncState = {
};
class SyncService {
private syncStates: Map<string, WorkspaceSyncState> = new Map();
private readonly logger = logService.createLogger('sync-service');
private readonly localSyncStates: Map<string, WorkspaceSyncState> = new Map();
private readonly syncingTransactions: Set<string> = new Set();
private readonly syncingCollaborations: Set<string> = new Set();
constructor() {
eventBus.subscribe((event) => {
if (event.type === 'change_created') {
this.syncWorkspace(event.userId);
if (event.type === 'node_transaction_created') {
this.syncLocalTransactions(event.userId);
} else if (event.type === 'workspace_created') {
this.requireNodeTransactions(event.workspace.userId);
}
});
}
@@ -36,19 +45,21 @@ class SyncService {
.execute();
for (const workspace of workspaces) {
this.syncWorkspace(workspace.user_id);
this.syncLocalTransactions(workspace.user_id);
this.requireNodeTransactions(workspace.user_id);
this.requireCollaborations(workspace.user_id);
}
}
public async syncWorkspace(userId: string) {
if (!this.syncStates.has(userId)) {
this.syncStates.set(userId, {
public async syncLocalTransactions(userId: string) {
if (!this.localSyncStates.has(userId)) {
this.localSyncStates.set(userId, {
isSyncing: false,
scheduledSync: false,
});
}
const syncState = this.syncStates.get(userId)!;
const syncState = this.localSyncStates.get(userId)!;
if (syncState.isSyncing) {
syncState.scheduledSync = true;
return;
@@ -56,26 +67,92 @@ class SyncService {
syncState.isSyncing = true;
try {
await this.syncWorkspaceChanges(userId);
await this.sendLocalTransactions(userId);
} catch (error) {
console.log('error', error);
this.logger.error(
error,
`Error syncing local transactions for user ${userId}`
);
} finally {
syncState.isSyncing = false;
if (syncState.scheduledSync) {
syncState.scheduledSync = false;
this.syncWorkspace(userId);
this.syncLocalTransactions(userId);
}
}
}
private async syncWorkspaceChanges(userId: string) {
public async syncServerTransactions(message: NodeTransactionsBatchMessage) {
if (this.syncingTransactions.has(message.userId)) {
return;
}
this.syncingTransactions.add(message.userId);
let cursor: bigint | null = null;
try {
for (const transaction of message.transactions) {
await nodeService.applyServerTransaction(message.userId, transaction);
cursor = BigInt(transaction.number);
}
if (cursor) {
this.updateNodeTransactionCursor(message.userId, cursor);
}
} catch (error) {
this.logger.error(
error,
`Error syncing server transactions for user ${message.userId}`
);
} finally {
this.syncingTransactions.delete(message.userId);
this.requireNodeTransactions(message.userId);
}
}
public async syncServerCollaborations(message: CollaborationsBatchMessage) {
if (this.syncingCollaborations.has(message.userId)) {
return;
}
this.syncingCollaborations.add(message.userId);
let cursor: bigint | null = null;
try {
for (const collaboration of message.collaborations) {
await collaborationService.applyServerCollaboration(
message.userId,
collaboration
);
cursor = BigInt(collaboration.number);
}
if (cursor) {
this.updateNodeCollaborationCursor(message.userId, cursor);
}
} catch (error) {
this.logger.error(
error,
`Error syncing server collaborations for user ${message.userId}`
);
} finally {
this.syncingCollaborations.delete(message.userId);
this.requireCollaborations(message.userId);
}
}
private async sendLocalTransactions(userId: string) {
const workspaceDatabase =
await databaseService.getWorkspaceDatabase(userId);
const changes =
await this.fetchAndCompactWorkspaceChanges(workspaceDatabase);
if (changes.length === 0) {
const unsyncedTransactions = await workspaceDatabase
.selectFrom('node_transactions')
.selectAll()
.where('status', '=', 'pending')
.orderBy('id', 'asc')
.limit(20)
.execute();
if (unsyncedTransactions.length === 0) {
return;
}
@@ -102,237 +179,135 @@ class SyncService {
return;
}
while (changes.length > 0) {
const changesToSync = changes.splice(0, 20);
const { data } = await httpClient.post<SyncChangesOutput>(
`/v1/sync/${workspace.workspace_id}`,
{
changes: changesToSync,
},
{
domain: workspace.domain,
token: workspace.token,
}
);
const syncedChangeIds: number[] = [];
const unsyncedChangeIds: number[] = [];
for (const result of data.results) {
if (result.status === 'success') {
syncedChangeIds.push(result.id);
} else {
unsyncedChangeIds.push(result.id);
}
}
if (syncedChangeIds.length > 0) {
await workspaceDatabase
.deleteFrom('changes')
.where('id', 'in', syncedChangeIds)
.execute();
}
if (unsyncedChangeIds.length > 0) {
await workspaceDatabase
.updateTable('changes')
.set((eb) => ({ retry_count: eb('retry_count', '+', 1) }))
.where('id', 'in', unsyncedChangeIds)
.execute();
}
}
const invalidChanges = await this.fetchInvalidChanges(workspaceDatabase);
if (invalidChanges.length === 0) {
return;
}
const nodeChanges: Record<string, number[]> = {};
const changesToDelete: number[] = [];
for (const change of invalidChanges) {
if (change.retryCount >= 100) {
// if the change has been retried 100 times, we delete it (it should never happen)
changesToDelete.push(change.id);
continue;
}
let nodeId: string | null = null;
if (
change.data.type === 'node_create' ||
change.data.type === 'node_update' ||
change.data.type === 'node_delete'
) {
nodeId = change.data.id;
} else if (change.data.type === 'user_node_update') {
nodeId = change.data.nodeId;
}
if (nodeId) {
const changeIds = nodeChanges[nodeId] ?? [];
changeIds.push(change.id);
nodeChanges[nodeId] = changeIds;
}
}
const nodeIds = Object.keys(nodeChanges);
const { data, status } = await httpClient.post<SyncNodeStatesOutput>(
`/v1/sync/states/${workspace.workspace_id}`,
const transactions: LocalNodeTransaction[] =
unsyncedTransactions.map(mapTransaction);
const { data } = await httpClient.post<SyncNodeTransactionsOutput>(
`/v1/sync/${workspace.workspace_id}`,
{
nodeIds,
transactions,
},
{ domain: workspace.domain, token: workspace.token }
{
domain: workspace.domain,
token: workspace.token,
}
);
if (status !== 200) {
const syncedTransactionIds: string[] = [];
const unsyncedTransactionIds: string[] = [];
for (const result of data.results) {
if (result.status === 'success') {
syncedTransactionIds.push(result.id);
} else {
unsyncedTransactionIds.push(result.id);
}
}
if (syncedTransactionIds.length > 0) {
await workspaceDatabase
.updateTable('node_transactions')
.set({ status: 'sent' })
.where('id', 'in', syncedTransactionIds)
.execute();
}
if (unsyncedTransactionIds.length > 0) {
await workspaceDatabase
.updateTable('node_transactions')
.set((eb) => ({ retry_count: eb('retry_count', '+', 1) }))
.where('id', 'in', unsyncedTransactionIds)
.execute();
}
}
private async requireNodeTransactions(userId: string) {
const workspaceWithCursor = await databaseService.appDatabase
.selectFrom('workspaces as w')
.leftJoin('workspace_cursors as wc', 'w.user_id', 'wc.user_id')
.select([
'w.user_id',
'w.workspace_id',
'w.account_id',
'wc.node_transactions',
])
.where('w.user_id', '=', userId)
.executeTakeFirst();
if (!workspaceWithCursor) {
return;
}
for (const nodeId of nodeIds) {
const changeIds = nodeChanges[nodeId] ?? [];
const states = data.nodes[nodeId];
const message: FetchNodeTransactionsMessage = {
type: 'fetch_node_transactions',
userId: workspaceWithCursor.user_id,
workspaceId: workspaceWithCursor.workspace_id,
cursor: workspaceWithCursor.node_transactions?.toString() ?? null,
};
if (!states) {
const deleted = await nodeService.serverDelete(userId, nodeId);
if (deleted) {
changesToDelete.push(...changeIds);
socketService.sendMessage(workspace.account_id, {
type: 'local_node_delete',
nodeId,
workspaceId: workspace.workspace_id,
});
}
continue;
}
const nodeSynced = await nodeService.serverUpsert(userId, states.node);
if (nodeSynced) {
changesToDelete.push(...changeIds);
socketService.sendMessage(workspace.account_id, {
type: 'local_node_sync',
nodeId,
userId,
versionId: states.node.versionId,
workspaceId: workspace.workspace_id,
});
const userNodeSynced = await nodeService.serverUserNodeSync(
userId,
states.userNode
);
if (userNodeSynced) {
socketService.sendMessage(workspace.account_id, {
type: 'local_user_node_sync',
nodeId,
userId,
versionId: states.userNode.versionId,
workspaceId: workspace.workspace_id,
});
}
changesToDelete.push(...changeIds);
}
}
if (changesToDelete.length > 0) {
await workspaceDatabase
.deleteFrom('changes')
.where('id', 'in', changesToDelete)
.execute();
}
socketService.sendMessage(workspaceWithCursor.account_id, message);
}
private async fetchAndCompactWorkspaceChanges(
database: Kysely<WorkspaceDatabaseSchema>
): Promise<LocalChange[]> {
const changeRows = await database
.selectFrom('changes')
.selectAll()
.orderBy('id asc')
.limit(1000)
.execute();
private async requireCollaborations(userId: string) {
const workspaceWithCursor = await databaseService.appDatabase
.selectFrom('workspaces as w')
.leftJoin('workspace_cursors as wc', 'w.user_id', 'wc.user_id')
.select([
'w.user_id',
'w.workspace_id',
'w.account_id',
'wc.collaborations',
])
.where('w.user_id', '=', userId)
.executeTakeFirst();
if (changeRows.length === 0) {
return [];
if (!workspaceWithCursor) {
return;
}
const changes: LocalChange[] = changeRows.map(mapChange);
const changesToDelete = new Set<number>();
for (let i = changes.length - 1; i >= 0; i--) {
const change = changes[i];
if (!change) {
continue;
}
const message: FetchCollaborationsMessage = {
type: 'fetch_collaborations',
userId: workspaceWithCursor.user_id,
workspaceId: workspaceWithCursor.workspace_id,
cursor: workspaceWithCursor.collaborations?.toString() ?? null,
};
if (changesToDelete.has(change.id)) {
continue;
}
if (change.data.type === 'node_delete') {
for (let j = i - 1; j >= 0; j--) {
const otherChange = changes[j];
if (!otherChange) {
continue;
}
if (
otherChange.data.type === 'node_create' &&
otherChange.data.id === change.data.id
) {
// if the node has been created and then deleted, we don't need to sync the delete
changesToDelete.add(change.id);
changesToDelete.add(otherChange.id);
}
if (
otherChange.data.type === 'node_update' &&
otherChange.data.id === change.data.id
) {
changesToDelete.add(otherChange.id);
}
}
} else if (change.data.type === 'user_node_update') {
for (let j = i - 1; j >= 0; j--) {
const otherChange = changes[j];
if (!otherChange) {
continue;
}
if (
otherChange.data.type === 'user_node_update' &&
otherChange.data.nodeId === change.data.nodeId &&
otherChange.data.userId === change.data.userId
) {
changesToDelete.add(otherChange.id);
}
}
}
}
if (changesToDelete.size > 0) {
const toDeleteIds = Array.from(changesToDelete);
await database
.deleteFrom('changes')
.where('id', 'in', toDeleteIds)
.execute();
}
return changes.filter((change) => !changesToDelete.has(change.id));
socketService.sendMessage(workspaceWithCursor.account_id, message);
}
private async fetchInvalidChanges(database: Kysely<WorkspaceDatabaseSchema>) {
const rows = await database
.selectFrom('changes')
.selectAll()
.where('retry_count', '>=', 5)
private async updateNodeTransactionCursor(userId: string, cursor: bigint) {
await databaseService.appDatabase
.insertInto('workspace_cursors')
.values({
user_id: userId,
node_transactions: cursor,
collaborations: 0n,
created_at: new Date().toISOString(),
})
.onConflict((eb) =>
eb.column('user_id').doUpdateSet({
node_transactions: cursor,
updated_at: new Date().toISOString(),
})
)
.execute();
}
if (rows.length === 0) {
return [];
}
return rows.map(mapChange);
private async updateNodeCollaborationCursor(userId: string, cursor: bigint) {
await databaseService.appDatabase
.insertInto('workspace_cursors')
.values({
user_id: userId,
collaborations: cursor,
node_transactions: 0n,
created_at: new Date().toISOString(),
})
.onConflict((eb) =>
eb.column('user_id').doUpdateSet({
collaborations: cursor,
updated_at: new Date().toISOString(),
})
)
.execute();
}
}

View File

@@ -8,12 +8,13 @@ import {
} from 'kysely';
import path from 'path';
import {
SelectChange,
SelectDownload,
SelectNode,
SelectNodeTransaction,
SelectUpload,
WorkspaceDatabaseSchema,
} from '@/main/data/workspace/schema';
import { LocalChange, Node, NodeTypes } from '@colanode/core';
import { LocalNodeTransaction, Node, NodeTypes } from '@colanode/core';
import { Account } from '@/shared/types/accounts';
import {
SelectAccount,
@@ -22,7 +23,8 @@ import {
} from './data/app/schema';
import { Workspace } from '@/shared/types/workspaces';
import { Server } from '@/shared/types/servers';
import { Download } from '@/shared/types/nodes';
import { Download, Upload } from '@/shared/types/nodes';
import { encodeState } from '@colanode/crdt';
export const appPath = app.getPath('userData');
@@ -105,10 +107,7 @@ export const mapNode = (row: SelectNode): Node => {
createdBy: row.created_by,
updatedAt: row.updated_at,
updatedBy: row.updated_by,
versionId: row.version_id,
serverCreatedAt: row.server_created_at,
serverUpdatedAt: row.server_updated_at,
serverVersionId: row.server_version_id,
transactionId: row.transaction_id,
};
};
@@ -138,13 +137,42 @@ export const mapWorkspace = (row: SelectWorkspace): Workspace => {
};
};
export const mapChange = (row: SelectChange): LocalChange => {
return {
id: row.id,
data: JSON.parse(row.data),
createdAt: row.created_at,
retryCount: row.retry_count,
};
export const mapTransaction = (
row: SelectNodeTransaction
): LocalNodeTransaction => {
if (row.type === 'create' && row.data) {
return {
id: row.id,
nodeId: row.node_id,
type: row.type,
data: encodeState(row.data),
createdAt: row.created_at,
createdBy: row.created_by,
};
}
if (row.type === 'update' && row.data) {
return {
id: row.id,
nodeId: row.node_id,
type: row.type,
data: encodeState(row.data),
createdAt: row.created_at,
createdBy: row.created_by,
};
}
if (row.type === 'delete') {
return {
id: row.id,
nodeId: row.node_id,
type: row.type,
createdAt: row.created_at,
createdBy: row.created_by,
};
}
throw new Error('Invalid transaction type');
};
export const mapServer = (row: SelectServer): Server => {
@@ -159,6 +187,16 @@ export const mapServer = (row: SelectServer): Server => {
};
};
export const mapUpload = (row: SelectUpload): Upload => {
return {
nodeId: row.node_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
progress: row.progress,
retryCount: row.retry_count,
};
};
export const mapDownload = (row: SelectDownload): Download => {
return {
nodeId: row.node_id,

View File

@@ -59,21 +59,21 @@ import { useMutation } from '@/renderer/hooks/use-mutation';
interface DocumentEditorProps {
documentId: string;
content: JSONContent;
versionId: string;
transactionId: string;
canEdit: boolean;
}
export const DocumentEditor = ({
documentId,
content,
versionId,
transactionId,
canEdit,
}: DocumentEditorProps) => {
const workspace = useWorkspace();
const { mutate } = useMutation();
const hasPendingChanges = React.useRef(false);
const versionIdRef = React.useRef(versionId);
const transactionIdRef = React.useRef(transactionId);
const debouncedSave = React.useMemo(
() =>
debounce((content: JSONContent) => {
@@ -179,7 +179,7 @@ export const DocumentEditor = ({
return;
}
if (versionIdRef.current === versionId) {
if (transactionIdRef.current === transactionId) {
return;
}
@@ -190,8 +190,8 @@ export const DocumentEditor = ({
editor.chain().setContent(content).run();
}
versionIdRef.current = versionId;
}, [content, versionId]);
transactionIdRef.current = transactionId;
}, [content, transactionId]);
return (
<div className="min-h-[500px]">

View File

@@ -5,14 +5,14 @@ import { mapBlocksToContents } from '@/shared/lib/editor';
interface DocumentProps {
nodeId: string;
content?: Record<string, Block> | null;
versionId: string;
transactionId: string;
canEdit: boolean;
}
export const Document = ({
nodeId,
content,
versionId,
transactionId,
canEdit,
}: DocumentProps) => {
const nodeBlocks = Object.values(content ?? {});
@@ -34,7 +34,7 @@ export const Document = ({
key={nodeId}
documentId={nodeId}
content={tiptapContent}
versionId={versionId}
transactionId={transactionId}
canEdit={canEdit}
/>
);

View File

@@ -61,7 +61,11 @@ export const Message = ({ message, previousMessage }: MessageProps) => {
rootMargin="50px"
onChange={(inView) => {
if (inView) {
radar.markAsSeen(workspace.userId, message.id, message.versionId);
radar.markAsSeen(
workspace.userId,
message.id,
message.transactionId
);
}
}}
>

View File

@@ -15,7 +15,7 @@ export const PageBody = ({ page, role }: PageBodyProps) => {
<Document
nodeId={page.id}
content={page.attributes.content}
versionId={page.versionId}
transactionId={page.transactionId}
canEdit={canEdit}
/>
</ScrollArea>

View File

@@ -39,7 +39,7 @@ export const RecordBody = ({
<Document
nodeId={record.id}
content={record.attributes.content}
versionId={record.versionId}
transactionId={record.transactionId}
canEdit={canEdit}
/>
</ScrollArea>

View File

@@ -156,7 +156,7 @@ export const RecordProvider = ({
return null;
},
versionId: record.versionId,
transactionId: record.transactionId,
}}
>
{children}

View File

@@ -20,7 +20,7 @@ export const RecordBooleanValue = ({
React.useEffect(() => {
setInput(record.getBooleanValue(field));
}, [record.versionId]);
}, [record.transactionId]);
return (
<div className="flex h-full w-full flex-row items-center justify-start p-0">

View File

@@ -27,7 +27,7 @@ export const RecordMultiSelectValue = ({
React.useEffect(() => {
setSelectedValues(record.getMultiSelectValue(field));
}, [record.versionId]);
}, [record.transactionId]);
const selectOptions = Object.values(field.options ?? {});
const selectedOptions = selectOptions.filter((option) =>

View File

@@ -27,7 +27,7 @@ export const RecordSelectValue = ({
React.useEffect(() => {
setSelectedValue(record.getSelectValue(field));
}, [record.versionId]);
}, [record.transactionId]);
const selectedOption = field.options?.[selectedValue ?? ''];

View File

@@ -26,7 +26,7 @@ interface RecordContext {
createdAt: string;
updatedBy?: string | null;
updatedAt?: string | null;
versionId: string;
transactionId: string;
canEdit: boolean;
updateFieldValue: (field: FieldAttributes, value: FieldValue) => void;
removeFieldValue: (field: FieldAttributes) => void;

View File

@@ -1,19 +0,0 @@
export type ServerNodeDeleteMutationInput = {
type: 'server_node_delete';
accountId: string;
id: string;
workspaceId: string;
};
export type ServerNodeDeleteMutationOutput = {
success: boolean;
};
declare module '@/shared/mutations' {
interface MutationMap {
server_node_delete: {
input: ServerNodeDeleteMutationInput;
output: ServerNodeDeleteMutationOutput;
};
}
}

View File

@@ -1,20 +0,0 @@
import { ServerNodeState } from '@colanode/core';
export type ServerNodeSyncMutationInput = {
type: 'server_node_sync';
accountId: string;
node: ServerNodeState;
};
export type ServerNodeSyncMutationOutput = {
success: boolean;
};
declare module '@/shared/mutations' {
interface MutationMap {
server_node_sync: {
input: ServerNodeSyncMutationInput;
output: ServerNodeSyncMutationOutput;
};
}
}

View File

@@ -1,20 +0,0 @@
import { ServerUserNodeState } from '@colanode/core';
export type ServerUserNodeSyncMutationInput = {
type: 'server_user_node_sync';
accountId: string;
userNode: ServerUserNodeState;
};
export type ServerUserNodeSyncMutationOutput = {
success: boolean;
};
declare module '@/shared/mutations' {
interface MutationMap {
server_user_node_sync: {
input: ServerUserNodeSyncMutationInput;
output: ServerUserNodeSyncMutationOutput;
};
}
}

View File

@@ -1,4 +1,4 @@
import { Node } from '@colanode/core';
import { LocalNodeTransaction, Node } from '@colanode/core';
import { Account } from '@/shared/types/accounts';
import { Workspace } from '@/shared/types/workspaces';
import { Server } from '@/shared/types/servers';
@@ -120,10 +120,10 @@ export type RadarDataUpdatedEvent = {
type: 'radar_data_updated';
};
export type ChangeCreatedEvent = {
type: 'change_created';
export type NodeTransactionCreatedEvent = {
type: 'node_transaction_created';
userId: string;
changeId: number;
transaction: LocalNodeTransaction;
};
export type ServerAvailabilityChangedEvent = {
@@ -154,5 +154,5 @@ export type Event =
| UploadDeletedEvent
| QueryResultUpdatedEvent
| RadarDataUpdatedEvent
| ChangeCreatedEvent
| NodeTransactionCreatedEvent
| ServerAvailabilityChangedEvent;

View File

@@ -107,19 +107,14 @@ const createNodesTable: Migration = {
col
.generatedAlwaysAs(sql`(attributes->>'parentId')::VARCHAR(30)`)
.stored()
.references('nodes.id')
.onDelete('cascade')
.notNull()
)
.addColumn('attributes', 'jsonb', (col) => col.notNull())
.addColumn('state', 'bytea', (col) => col.notNull())
.addColumn('created_at', 'timestamptz', (col) => col.notNull())
.addColumn('updated_at', 'timestamptz')
.addColumn('created_by', 'varchar(30)', (col) => col.notNull())
.addColumn('updated_by', 'varchar(30)')
.addColumn('version_id', 'varchar(30)', (col) => col.notNull())
.addColumn('server_created_at', 'timestamptz', (col) => col.notNull())
.addColumn('server_updated_at', 'timestamptz')
.addColumn('transaction_id', 'varchar(30)', (col) => col.notNull())
.execute();
},
down: async (db) => {
@@ -127,6 +122,38 @@ const createNodesTable: Migration = {
},
};
const createNodeTransactionsTable: Migration = {
up: async (db) => {
await sql`
CREATE SEQUENCE IF NOT EXISTS node_transactions_number_seq
START WITH 1000000000
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
`.execute(db);
await db.schema
.createTable('node_transactions')
.addColumn('id', 'varchar(30)', (col) => col.notNull().primaryKey())
.addColumn('workspace_id', 'varchar(30)', (col) => col.notNull())
.addColumn('node_id', 'varchar(30)', (col) => col.notNull())
.addColumn('type', 'varchar(30)', (col) => col.notNull())
.addColumn('data', 'bytea')
.addColumn('created_at', 'timestamptz', (col) => col.notNull())
.addColumn('created_by', 'varchar(30)', (col) => col.notNull())
.addColumn('server_created_at', 'timestamptz', (col) => col.notNull())
.addColumn('number', 'bigint', (col) =>
col.notNull().defaultTo(sql`nextval('node_transactions_number_seq')`)
)
.execute();
},
down: async (db) => {
await db.schema.dropTable('node_transactions').execute();
await sql`DROP SEQUENCE IF EXISTS node_transactions_number_seq`.execute(db);
},
};
const createNodePathsTable: Migration = {
up: async (db) => {
await db.schema
@@ -201,52 +228,60 @@ const createNodePathsTable: Migration = {
},
};
const createUserNodesTable: Migration = {
const createCollaborationsTable: Migration = {
up: async (db) => {
await sql`
CREATE SEQUENCE IF NOT EXISTS collaboration_number_seq
START WITH 1000000000
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
`.execute(db);
await db.schema
.createTable('user_nodes')
.createTable('collaborations')
.addColumn('user_id', 'varchar(30)', (col) => col.notNull())
.addColumn('node_id', 'varchar(30)', (col) => col.notNull())
.addColumn('workspace_id', 'varchar(30)', (col) => col.notNull())
.addColumn('last_seen_version_id', 'varchar(30)')
.addColumn('last_seen_at', 'timestamptz')
.addColumn('mentions_count', 'integer', (col) =>
col.notNull().defaultTo(0)
.addColumn('type', 'varchar(30)', (col) =>
col.generatedAlwaysAs(sql`(attributes->>'type')::VARCHAR(30)`).stored()
)
.addColumn('attributes', 'jsonb')
.addColumn('workspace_id', 'varchar(30)', (col) => col.notNull())
.addColumn('attributes', 'jsonb', (col) => col.notNull())
.addColumn('state', 'bytea', (col) => col.notNull())
.addColumn('created_at', 'timestamptz', (col) => col.notNull())
.addColumn('updated_at', 'timestamptz')
.addColumn('access_removed_at', 'timestamptz')
.addColumn('version_id', 'varchar(30)', (col) => col.notNull())
.addPrimaryKeyConstraint('user_nodes_pkey', ['user_id', 'node_id'])
.addColumn('deleted_at', 'timestamptz')
.addColumn('number', 'bigint', (col) =>
col.notNull().defaultTo(sql`nextval('collaboration_number_seq')`)
)
.addPrimaryKeyConstraint('collaborations_pkey', ['user_id', 'node_id'])
.execute();
},
down: async (db) => {
await db.schema.dropTable('user_nodes').execute();
},
};
const createDeviceNodesTable: Migration = {
up: async (db) => {
await db.schema
.createTable('device_nodes')
.addColumn('device_id', 'varchar(30)', (col) => col.notNull())
.addColumn('user_id', 'varchar(30)', (col) => col.notNull())
.addColumn('node_id', 'varchar(30)', (col) => col.notNull())
.addColumn('workspace_id', 'varchar(30)', (col) => col.notNull())
.addColumn('node_version_id', 'varchar(30)')
.addColumn('user_node_version_id', 'varchar(30)')
.addColumn('node_synced_at', 'timestamptz')
.addColumn('user_node_synced_at', 'timestamptz')
.addPrimaryKeyConstraint('device_nodes_pkey', [
'device_id',
'user_id',
'node_id',
])
.execute();
// Add trigger to update number on each update
await sql`
CREATE OR REPLACE FUNCTION fn_update_collaboration_number() RETURNS TRIGGER AS $$
BEGIN
NEW.number = nextval('collaboration_number_seq');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_collaboration_number
BEFORE UPDATE ON collaborations
FOR EACH ROW
EXECUTE FUNCTION fn_update_collaboration_number();
`.execute(db);
},
down: async (db) => {
await db.schema.dropTable('device_nodes').execute();
// Drop trigger and function first
await sql`
DROP TRIGGER IF EXISTS trg_update_collaboration_number ON collaborations;
DROP FUNCTION IF EXISTS fn_update_collaboration_number();
DROP SEQUENCE IF EXISTS collaboration_number_seq;
`.execute(db);
await db.schema.dropTable('collaborations').execute();
},
};
@@ -279,8 +314,8 @@ export const databaseMigrations: Record<string, Migration> = {
'00003_create_workspaces_table': createWorkspacesTable,
'00004_create_workspace_users_table': createWorkspaceUsersTable,
'00005_create_nodes_table': createNodesTable,
'00006_create_node_paths_table': createNodePathsTable,
'00007_create_user_nodes_table': createUserNodesTable,
'00008_create_device_nodes_table': createDeviceNodesTable,
'00006_create_node_transactions_table': createNodeTransactionsTable,
'00007_create_node_paths_table': createNodePathsTable,
'00008_create_collaborations_table': createCollaborationsTable,
'00009_create_uploads_table': createUploadsTable,
};

View File

@@ -87,20 +87,50 @@ interface NodeTable {
parent_id: ColumnType<string, never, never>;
type: ColumnType<string, never, never>;
attributes: JSONColumnType<NodeAttributes, string | null, string | null>;
state: ColumnType<Uint8Array, Uint8Array, Uint8Array>;
created_at: ColumnType<Date, Date, never>;
updated_at: ColumnType<Date | null, Date | null, Date>;
created_by: ColumnType<string, string, never>;
updated_by: ColumnType<string | null, string | null, string>;
version_id: ColumnType<string, string, string>;
server_created_at: ColumnType<Date, Date, never>;
server_updated_at: ColumnType<Date | null, Date | null, Date>;
transaction_id: ColumnType<string, string, string>;
}
export type SelectNode = Selectable<NodeTable>;
export type CreateNode = Insertable<NodeTable>;
export type UpdateNode = Updateable<NodeTable>;
interface NodeTransactionTable {
id: ColumnType<string, string, never>;
node_id: ColumnType<string, string, never>;
workspace_id: ColumnType<string, string, never>;
type: ColumnType<string, string, never>;
data: ColumnType<Uint8Array | null, Uint8Array | null, Uint8Array | null>;
created_at: ColumnType<Date, Date, never>;
created_by: ColumnType<string, string, never>;
server_created_at: ColumnType<Date, Date, never>;
number: ColumnType<bigint, never, never>;
}
export type SelectNodeTransaction = Selectable<NodeTransactionTable>;
export type CreateNodeTransaction = Insertable<NodeTransactionTable>;
export type UpdateNodeTransaction = Updateable<NodeTransactionTable>;
interface CollaborationTable {
user_id: ColumnType<string, string, never>;
node_id: ColumnType<string, string, never>;
type: ColumnType<string, never, never>;
workspace_id: ColumnType<string, string, never>;
attributes: JSONColumnType<any, string, string>;
state: ColumnType<Uint8Array, Uint8Array, Uint8Array>;
created_at: ColumnType<Date, Date, never>;
updated_at: ColumnType<Date | null, Date | null, Date>;
deleted_at: ColumnType<Date | null, Date | null, Date>;
number: ColumnType<bigint, never, never>;
}
export type SelectCollaboration = Selectable<CollaborationTable>;
export type CreateCollaboration = Insertable<CollaborationTable>;
export type UpdateCollaboration = Updateable<CollaborationTable>;
interface NodePathTable {
ancestor_id: ColumnType<string, string, never>;
descendant_id: ColumnType<string, string, never>;
@@ -112,39 +142,6 @@ export type SelectNodePath = Selectable<NodePathTable>;
export type CreateNodePath = Insertable<NodePathTable>;
export type UpdateNodePath = Updateable<NodePathTable>;
interface UserNodeTable {
node_id: ColumnType<string, string, never>;
user_id: ColumnType<string, string, never>;
workspace_id: ColumnType<string, string, never>;
last_seen_version_id: ColumnType<string | null, string | null, string | null>;
last_seen_at: ColumnType<Date | null, Date | null, Date>;
mentions_count: ColumnType<number, number, number>;
attributes: JSONColumnType<any, string | null, string | null>;
created_at: ColumnType<Date, Date, never>;
updated_at: ColumnType<Date | null, Date | null, Date>;
access_removed_at: ColumnType<Date | null, Date | null, Date>;
version_id: ColumnType<string, string, string>;
}
export type SelectUserNode = Selectable<UserNodeTable>;
export type CreateUserNode = Insertable<UserNodeTable>;
export type UpdateUserNode = Updateable<UserNodeTable>;
interface DeviceNodeTable {
device_id: ColumnType<string, string, never>;
user_id: ColumnType<string, string, never>;
node_id: ColumnType<string, string, never>;
workspace_id: ColumnType<string, string, string>;
node_version_id: ColumnType<string | null, string | null, string | null>;
user_node_version_id: ColumnType<string | null, string | null, string | null>;
node_synced_at: ColumnType<Date | null, Date | null, Date>;
user_node_synced_at: ColumnType<Date | null, Date | null, Date>;
}
export type SelectDeviceNode = Selectable<DeviceNodeTable>;
export type CreateDeviceNode = Insertable<DeviceNodeTable>;
export type UpdateDeviceNode = Updateable<DeviceNodeTable>;
interface UploadTable {
node_id: ColumnType<string, string, never>;
upload_id: ColumnType<string, string, never>;
@@ -164,8 +161,8 @@ export interface DatabaseSchema {
workspaces: WorkspaceTable;
workspace_users: WorkspaceUserTable;
nodes: NodeTable;
node_transactions: NodeTransactionTable;
collaborations: CollaborationTable;
node_paths: NodePathTable;
user_nodes: UserNodeTable;
device_nodes: DeviceNodeTable;
uploads: UploadTable;
}

View File

@@ -1,9 +1,7 @@
import { initApi } from '@/api';
import { initRedis } from '@/data/redis';
import { migrate } from '@/data/database';
import { initEventWorker } from '@/queues/events';
import { initTaskWorker } from '@/queues/tasks';
// import { initEmail } from '@/services/email';
import dotenv from 'dotenv';
dotenv.config();
@@ -11,10 +9,8 @@ dotenv.config();
const init = async () => {
await migrate();
await initRedis();
// await initEmail();
await initApi();
initEventWorker();
initTaskWorker();
};

View File

@@ -0,0 +1,28 @@
import { CreateCollaboration } from '@/data/schema';
import { CollaborationAttributes, NodeType, registry } from '@colanode/core';
import { YDoc } from '@colanode/crdt';
export const buildDefaultCollaboration = (
userId: string,
nodeId: string,
type: NodeType,
workspaceId: string
): CreateCollaboration => {
const model = registry.getCollaborationModel(type);
const attributes: CollaborationAttributes = {
type,
};
const ydoc = new YDoc();
ydoc.updateAttributes(model.schema, attributes);
return {
user_id: userId,
node_id: nodeId,
workspace_id: workspaceId,
attributes: JSON.stringify(ydoc.getAttributes()),
state: ydoc.getState(),
created_at: new Date(),
};
};

View File

@@ -0,0 +1,44 @@
import { Event } from '@/types/events';
export interface Subscription {
id: string;
callback: (event: Event) => void;
}
export interface EventBus {
subscribe(callback: (event: Event) => void): string;
unsubscribe(subscriptionId: string): void;
publish(event: Event): void;
}
export class EventBusService {
private subscriptions: Map<string, Subscription>;
private id = 0;
public constructor() {
this.subscriptions = new Map<string, Subscription>();
}
public subscribe(callback: (event: Event) => void): string {
const id = (this.id++).toLocaleString();
this.subscriptions.set(id, {
callback,
id,
});
return id;
}
public unsubscribe(subscriptionId: string) {
if (!this.subscriptions.has(subscriptionId)) return;
this.subscriptions.delete(subscriptionId);
}
public publish(event: Event) {
this.subscriptions.forEach((subscription) => {
subscription.callback(event);
});
}
}
export const eventBus = new EventBusService();

View File

@@ -1,14 +1,22 @@
import { database } from '@/data/database';
import { SelectNode } from '@/data/schema';
import {
SelectCollaboration,
SelectNode,
SelectNodeTransaction,
} from '@/data/schema';
import { NodeCollaborator } from '@/types/nodes';
import { NodeOutput } from '@colanode/core';
import { fromUint8Array } from 'js-base64';
import {
NodeOutput,
ServerCollaboration,
ServerNodeTransaction,
} from '@colanode/core';
import {
extractNodeCollaborators,
extractNodeRole,
Node,
NodeType,
} from '@colanode/core';
import { encodeState } from '@colanode/crdt';
export const mapNodeOutput = (node: SelectNode): NodeOutput => {
return {
@@ -17,14 +25,12 @@ export const mapNodeOutput = (node: SelectNode): NodeOutput => {
workspaceId: node.workspace_id,
type: node.type,
attributes: node.attributes,
state: fromUint8Array(node.state),
state: '',
createdAt: node.created_at.toISOString(),
createdBy: node.created_by,
versionId: node.version_id,
transactionId: node.transaction_id,
updatedAt: node.updated_at?.toISOString() ?? null,
updatedBy: node.updated_by ?? null,
serverCreatedAt: node.server_created_at.toISOString(),
serverUpdatedAt: node.server_updated_at?.toISOString() ?? null,
};
};
@@ -38,13 +44,73 @@ export const mapNode = (node: SelectNode): Node => {
createdBy: node.created_by,
updatedAt: node.updated_at?.toISOString() ?? null,
updatedBy: node.updated_by ?? null,
versionId: node.version_id,
serverCreatedAt: node.server_created_at.toISOString(),
serverUpdatedAt: node.server_updated_at?.toISOString() ?? null,
serverVersionId: node.version_id,
transactionId: node.transaction_id,
} as Node;
};
export const mapNodeTransaction = (
transaction: SelectNodeTransaction
): ServerNodeTransaction => {
if (transaction.type === 'create' && transaction.data) {
return {
id: transaction.id,
type: 'create',
nodeId: transaction.node_id,
workspaceId: transaction.workspace_id,
data: encodeState(transaction.data),
createdAt: transaction.created_at.toISOString(),
createdBy: transaction.created_by,
serverCreatedAt: transaction.server_created_at.toISOString(),
number: transaction.number.toString(),
};
}
if (transaction.type === 'update' && transaction.data) {
return {
id: transaction.id,
type: 'update',
nodeId: transaction.node_id,
workspaceId: transaction.workspace_id,
data: encodeState(transaction.data),
createdAt: transaction.created_at.toISOString(),
createdBy: transaction.created_by,
serverCreatedAt: transaction.server_created_at.toISOString(),
number: transaction.number.toString(),
};
}
if (transaction.type === 'delete') {
return {
id: transaction.id,
type: 'delete',
nodeId: transaction.node_id,
workspaceId: transaction.workspace_id,
createdAt: transaction.created_at.toISOString(),
createdBy: transaction.created_by,
serverCreatedAt: transaction.server_created_at.toISOString(),
number: transaction.number.toString(),
};
}
throw new Error('Unknown transaction type');
};
export const mapCollaboration = (
collaboration: SelectCollaboration
): ServerCollaboration => {
return {
userId: collaboration.user_id,
nodeId: collaboration.node_id,
type: collaboration.type,
workspaceId: collaboration.workspace_id,
state: encodeState(collaboration.state),
createdAt: collaboration.created_at.toISOString(),
updatedAt: collaboration.updated_at?.toISOString() ?? null,
deletedAt: collaboration.deleted_at?.toISOString() ?? null,
number: collaboration.number.toString(),
};
};
export const fetchNode = async (nodeId: string): Promise<SelectNode | null> => {
const result = await database
.selectFrom('nodes')

View File

@@ -1,241 +0,0 @@
import { database } from '@/data/database';
import { CreateNode, SelectAccount } from '@/data/schema';
import { WorkspaceStatus, WorkspaceUserStatus } from '@colanode/core';
import {
ChannelAttributes,
generateId,
IdType,
NodeRoles,
PageAttributes,
SpaceAttributes,
UserAttributes,
WorkspaceAttributes,
} from '@colanode/core';
import { NodeCreatedEvent } from '@/types/events';
import { enqueueEvent } from '@/queues/events';
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 = [workspace, user, space, page, channel];
await database.transaction().execute(async (trx) => {
await trx
.insertInto('workspaces')
.values({
id: workspaceId,
name: workspaceName,
description: 'Personal workspace for ' + account.name,
avatar: account.avatar,
created_at: createdAt,
created_by: account.id,
status: WorkspaceStatus.Active,
version_id: generateId(IdType.Version),
})
.execute();
await trx
.insertInto('workspace_users')
.values({
id: user.id,
account_id: account.id,
workspace_id: workspaceId,
role: 'owner',
created_at: createdAt,
created_by: account.id,
status: WorkspaceUserStatus.Active,
version_id: generateId(IdType.Version),
})
.execute();
await trx.insertInto('nodes').values(nodesToCreate).execute();
});
for (const node of nodesToCreate) {
const event = buildNodeCreateEvent(node);
await enqueueEvent(event);
}
};
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 attributes: UserAttributes = {
type: 'user',
name: account.name,
avatar: account.avatar,
email: account.email,
role: 'owner',
accountId: account.id,
parentId: workspaceId,
};
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
workspace_id: workspaceId,
created_at: new Date(),
created_by: account.id,
version_id: versionId,
server_created_at: new Date(),
attributes: JSON.stringify(attributes),
state,
};
};
const buildSpaceNodeCreate = (
workspaceId: string,
userId: string
): CreateNode => {
const id = generateId(IdType.Space);
const versionId = generateId(IdType.Version);
const attributes: SpaceAttributes = {
type: 'space',
name: 'Home',
description: 'Home space',
parentId: workspaceId,
collaborators: {
[userId]: NodeRoles.Admin,
},
};
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
workspace_id: workspaceId,
created_at: new Date(),
created_by: userId,
version_id: versionId,
server_created_at: new Date(),
attributes: JSON.stringify(attributes),
state,
};
};
const buildPageNodeCreate = (
workspaceId: string,
spaceId: string,
userId: string
): CreateNode => {
const id = generateId(IdType.Page);
const versionId = generateId(IdType.Version);
const attributes: PageAttributes = {
type: 'page',
name: 'Notes',
parentId: spaceId,
content: {},
};
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
workspace_id: workspaceId,
created_at: new Date(),
created_by: userId,
version_id: versionId,
server_created_at: new Date(),
attributes: JSON.stringify(attributes),
state,
};
};
const buildChannelNodeCreate = (
workspaceId: string,
spaceId: string,
userId: string
): CreateNode => {
const id = generateId(IdType.Channel);
const versionId = generateId(IdType.Version);
const attributes: ChannelAttributes = {
type: 'channel',
name: 'Discussions',
parentId: spaceId,
};
const ydoc = new YDoc(id);
ydoc.updateAttributes(attributes);
const state = ydoc.getState();
return {
id,
workspace_id: workspaceId,
created_at: new Date(),
created_by: userId,
version_id: versionId,
server_created_at: new Date(),
attributes: JSON.stringify(attributes),
state,
};
};
const buildNodeCreateEvent = (node: CreateNode): NodeCreatedEvent => {
return {
type: 'node_created',
id: node.id,
workspaceId: node.workspace_id,
attributes: JSON.parse(node.attributes ?? '{}'),
createdBy: node.created_by,
createdAt: node.created_at.toISOString(),
versionId: node.version_id,
serverCreatedAt: node.server_created_at.toISOString(),
};
};

View File

@@ -1,350 +0,0 @@
import { database } from '@/data/database';
import { redisConfig } from '@/data/redis';
import { CreateUserNode } from '@/data/schema';
import { filesStorage } from '@/data/storage';
import { BUCKET_NAMES } from '@/data/storage';
import {
extractNodeCollaborators,
generateId,
IdType,
NodeTypes,
} from '@colanode/core';
import { fetchNodeCollaborators, fetchWorkspaceUsers } from '@/lib/nodes';
import { synapse } from '@/services/synapse';
import {
NodeCreatedEvent,
NodeDeletedEvent,
NodeEvent,
NodeUpdatedEvent,
} from '@/types/events';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { Job, Queue, Worker } from 'bullmq';
import { difference } from 'lodash-es';
import { enqueueTask } from '@/queues/tasks';
import { logService } from '@/services/log';
const logger = logService.createLogger('events');
const eventQueue = new Queue('events', {
connection: {
host: redisConfig.host,
password: redisConfig.password,
port: redisConfig.port,
db: redisConfig.db,
},
defaultJobOptions: {
removeOnComplete: true,
},
});
export const enqueueEvent = async (event: NodeEvent): Promise<void> => {
logger.trace(event, 'Enqueuing event');
await eventQueue.add('event', event);
};
export const initEventWorker = () => {
logger.info('Initializing event worker');
return new Worker('events', handleEventJob, {
connection: {
host: redisConfig.host,
password: redisConfig.password,
port: redisConfig.port,
db: redisConfig.db,
},
});
};
const handleEventJob = async (job: Job) => {
const event = job.data as NodeEvent;
logger.trace(event, 'Handling event');
switch (event.type) {
case 'node_created':
return handleNodeCreatedEvent(event);
case 'node_updated':
return handleNodeUpdatedEvent(event);
case 'node_deleted':
return handleNodeDeletedEvent(event);
}
};
const handleNodeCreatedEvent = async (
event: NodeCreatedEvent
): Promise<void> => {
await createUserNodes(event);
await synapse.sendSynapseMessage({
type: 'node_create',
nodeId: event.id,
workspaceId: event.workspaceId,
});
};
const handleNodeUpdatedEvent = async (
event: NodeUpdatedEvent
): Promise<void> => {
await checkForCollaboratorsChange(event);
await checkForUserRoleChange(event);
await synapse.sendSynapseMessage({
type: 'node_update',
nodeId: event.id,
workspaceId: event.workspaceId,
});
};
const handleNodeDeletedEvent = async (
event: NodeDeletedEvent
): Promise<void> => {
logger.trace(event, 'Handling node deleted event');
if (event.attributes.type === 'file') {
const command = new DeleteObjectCommand({
Bucket: BUCKET_NAMES.FILES,
Key: `files/${event.id}${event.attributes.extension}`,
});
await filesStorage.send(command);
}
await synapse.sendSynapseMessage({
type: 'node_delete',
nodeId: event.id,
workspaceId: event.workspaceId,
});
};
const createUserNodes = async (event: NodeCreatedEvent): Promise<void> => {
logger.trace(event, 'Creating user nodes');
const userNodesToCreate: CreateUserNode[] = [];
if (event.attributes.type === NodeTypes.User) {
userNodesToCreate.push({
user_id: event.id,
node_id: event.workspaceId,
workspace_id: event.workspaceId,
mentions_count: 0,
created_at: new Date(),
version_id: generateId(IdType.Version),
});
const userIds = await fetchWorkspaceUsers(event.workspaceId);
for (const userId of userIds) {
userNodesToCreate.push({
user_id: userId,
node_id: event.id,
workspace_id: event.workspaceId,
last_seen_at: null,
last_seen_version_id: null,
mentions_count: 0,
created_at: new Date(),
access_removed_at: null,
version_id: generateId(IdType.Version),
updated_at: null,
});
if (userId === event.id) {
continue;
}
userNodesToCreate.push({
user_id: event.id,
node_id: userId,
workspace_id: event.workspaceId,
last_seen_version_id: null,
last_seen_at: null,
mentions_count: 0,
created_at: new Date(),
access_removed_at: null,
version_id: generateId(IdType.Version),
updated_at: null,
});
}
} else if (event.attributes.type === NodeTypes.Workspace) {
const userIds = await fetchWorkspaceUsers(event.workspaceId);
for (const userId of userIds) {
userNodesToCreate.push({
user_id: userId,
node_id: event.id,
workspace_id: event.workspaceId,
last_seen_at: null,
last_seen_version_id: null,
mentions_count: 0,
created_at: new Date(),
access_removed_at: null,
version_id: generateId(IdType.Version),
updated_at: null,
});
}
} else {
const collaborators = await fetchNodeCollaborators(event.id);
const collaboratorIds = collaborators.map((c) => c.collaboratorId);
for (const collaboratorId of collaboratorIds) {
userNodesToCreate.push({
user_id: collaboratorId,
node_id: event.id,
workspace_id: event.workspaceId,
last_seen_at:
collaboratorId === event.createdBy ? new Date(event.createdAt) : null,
last_seen_version_id:
collaboratorId === event.createdBy ? event.versionId : null,
mentions_count: 0,
created_at: new Date(),
access_removed_at: null,
version_id: generateId(IdType.Version),
updated_at: null,
});
}
}
if (userNodesToCreate.length > 0) {
logger.trace(userNodesToCreate, 'Creating user nodes');
await database
.insertInto('user_nodes')
.values(userNodesToCreate)
.onConflict((cb) => cb.doNothing())
.execute();
}
};
const checkForCollaboratorsChange = async (
event: NodeUpdatedEvent
): Promise<void> => {
logger.trace(event, 'Checking for collaborators change');
const beforeCollaborators = Object.keys(
extractNodeCollaborators(event.beforeAttributes)
);
const afterCollaborators = Object.keys(
extractNodeCollaborators(event.afterAttributes)
);
if (beforeCollaborators.length === 0 && afterCollaborators.length === 0) {
return;
}
const addedCollaborators = difference(
afterCollaborators,
beforeCollaborators
);
const removedCollaborators = difference(
beforeCollaborators,
afterCollaborators
);
if (addedCollaborators.length === 0 && removedCollaborators.length === 0) {
return;
}
if (addedCollaborators.length > 0) {
const existingUserNodes = await database
.selectFrom('user_nodes')
.select('user_id')
.where('node_id', '=', event.id)
.where('user_id', 'in', addedCollaborators)
.execute();
const existingCollaboratorIds = existingUserNodes.map((e) => e.user_id);
const actualAddedCollaborators = difference(
addedCollaborators,
existingCollaboratorIds
);
if (actualAddedCollaborators.length > 0) {
const descendants = await database
.selectFrom('node_paths')
.select('descendant_id')
.where('ancestor_id', '=', event.id)
.execute();
const descendantIds = descendants.map((d) => d.descendant_id);
const userNodesToCreate: CreateUserNode[] = [];
for (const collaboratorId of addedCollaborators) {
for (const descendantId of descendantIds) {
userNodesToCreate.push({
user_id: collaboratorId,
node_id: descendantId,
last_seen_version_id: null,
workspace_id: event.workspaceId,
last_seen_at: null,
mentions_count: 0,
created_at: new Date(),
access_removed_at: null,
version_id: generateId(IdType.Version),
});
}
}
if (userNodesToCreate.length > 0) {
await database
.insertInto('user_nodes')
.values(userNodesToCreate)
.onConflict((cb) => cb.doNothing())
.execute();
}
}
}
if (removedCollaborators.length > 0) {
const nodeCollaborators = await fetchNodeCollaborators(event.id);
const nodeCollaboratorIds = nodeCollaborators.map((c) => c.collaboratorId);
const actualRemovedCollaborators = difference(
removedCollaborators,
nodeCollaboratorIds
);
if (actualRemovedCollaborators.length > 0) {
const descendants = await database
.selectFrom('node_paths')
.select('descendant_id')
.where('ancestor_id', '=', event.id)
.execute();
const descendantIds = descendants.map((d) => d.descendant_id);
await database
.updateTable('user_nodes')
.set({
access_removed_at: new Date(),
})
.where('user_id', 'in', actualRemovedCollaborators)
.where('node_id', 'in', descendantIds)
.execute();
}
}
};
const checkForUserRoleChange = async (
event: NodeUpdatedEvent
): Promise<void> => {
logger.trace(event, 'Checking for user role change');
if (
event.beforeAttributes.type !== 'user' ||
event.afterAttributes.type !== 'user'
) {
return;
}
const beforeRole = event.beforeAttributes.role;
const afterRole = event.afterAttributes.role;
if (beforeRole === afterRole) {
return;
}
if (afterRole === 'none') {
await enqueueTask({
type: 'clean_user_device_nodes',
userId: event.id,
workspaceId: event.workspaceId,
});
}
};

View File

@@ -1,11 +1,5 @@
import { database } from '@/data/database';
import { redisConfig } from '@/data/redis';
import {
CleanDeviceDataTask,
CleanUserDeviceNodesTask,
SendEmailTask,
Task,
} from '@/types/tasks';
import { SendEmailTask, Task } from '@/types/tasks';
import { Job, Queue, Worker } from 'bullmq';
import { sendEmail } from '@/services/email';
@@ -40,68 +34,11 @@ const handleTaskJob = async (job: Job) => {
const task = job.data as Task;
switch (task.type) {
case 'clean_device_data':
return handleCleanDeviceDataTask(task);
case 'send_email':
return handleSendEmailTask(task);
case 'clean_user_device_nodes':
return handleCleanUserDeviceNodesTask(task);
}
};
const handleCleanDeviceDataTask = async (
task: CleanDeviceDataTask
): Promise<void> => {
const device = await database
.selectFrom('devices')
.where('id', '=', task.deviceId)
.selectAll()
.executeTakeFirst();
if (device) {
//device is still active
return;
}
await database
.deleteFrom('device_nodes')
.where('device_id', '=', task.deviceId)
.execute();
};
const handleSendEmailTask = async (task: SendEmailTask): Promise<void> => {
await sendEmail(task.message);
};
const handleCleanUserDeviceNodesTask = async (
task: CleanUserDeviceNodesTask
): Promise<void> => {
const workspaceUser = await database
.selectFrom('workspace_users')
.where('id', '=', task.userId)
.where('workspace_id', '=', task.workspaceId)
.selectAll()
.executeTakeFirst();
if (!workspaceUser) {
return;
}
const devices = await database
.selectFrom('devices')
.select('id')
.where('account_id', '=', task.userId)
.execute();
if (devices.length === 0) {
return;
}
const deviceIds = devices.map((d) => d.id);
await database
.deleteFrom('device_nodes')
.where('device_id', 'in', deviceIds)
.where('user_id', '=', task.userId)
.execute();
};

View File

@@ -20,14 +20,10 @@ import { database } from '@/data/database';
import bcrypt from 'bcrypt';
import { authMiddleware } from '@/middlewares/auth';
import { generateToken } from '@/lib/tokens';
import { enqueueTask } from '@/queues/tasks';
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 { workspaceService } from '@/services/workspace-service';
import { sha256 } from 'js-sha256';
import { YDoc } from '@colanode/crdt';
import { nodeService } from '@/services/node-service';
const GoogleUserInfoUrl = 'https://www.googleapis.com/oauth2/v1/userinfo';
const SaltRounds = 10;
@@ -230,11 +226,6 @@ accountsRouter.delete(
.where('id', '=', req.account.deviceId)
.execute();
await enqueueTask({
type: 'clean_device_data',
deviceId: req.account.deviceId,
});
return res.status(200).end();
}
);
@@ -283,6 +274,16 @@ accountsRouter.put(
});
}
await database
.updateTable('accounts')
.set({
name: input.name,
avatar: input.avatar,
updated_at: new Date(),
})
.where('id', '=', req.account.id)
.execute();
const users = await database
.selectFrom('nodes')
.selectAll()
@@ -296,21 +297,6 @@ accountsRouter.put(
)
.execute();
const updates: CompiledQuery[] = [];
const events: NodeUpdatedEvent[] = [];
updates.push(
database
.updateTable('accounts')
.set({
name: input.name,
avatar: input.avatar,
updated_at: new Date(),
})
.where('id', '=', req.account.id)
.compile()
);
for (const user of users) {
if (user.attributes.type !== 'user') {
throw new Error('User node not found.');
@@ -323,57 +309,22 @@ accountsRouter.put(
continue;
}
const ydoc = new YDoc(user.id, user.state);
ydoc.updateAttributes({
...user.attributes,
name: input.name,
avatar: input.avatar ?? null,
});
const attributes = ydoc.getAttributes();
const state = ydoc.getState();
const updatedAt = new Date();
const versionId = generateId(IdType.Version);
updates.push(
database
.updateTable('nodes')
.set({
attributes: JSON.stringify(attributes),
state: state,
updated_at: updatedAt,
updated_by: user.id,
version_id: versionId,
server_updated_at: updatedAt,
})
.where('id', '=', user.id)
.compile()
);
const event: NodeUpdatedEvent = {
type: 'node_updated',
id: user.id,
await nodeService.updateNode({
nodeId: user.id,
userId: user.created_by,
workspaceId: user.workspace_id,
beforeAttributes: user.attributes,
afterAttributes: attributes,
updatedBy: user.id,
updatedAt: updatedAt.toISOString(),
serverUpdatedAt: updatedAt.toISOString(),
versionId,
};
updater: (attributes) => {
if (attributes.type !== 'user') {
return null;
}
events.push(event);
}
await database.transaction().execute(async (trx) => {
for (const update of updates) {
await trx.executeQuery(update);
}
});
for (const event of events) {
await enqueueEvent(event);
return {
...attributes,
name: input.name,
avatar: input.avatar,
};
},
});
}
const output: AccountUpdateOutput = {
@@ -466,22 +417,12 @@ accountsRouter.get(
const buildLoginOutput = async (
account: SelectAccount
): Promise<LoginOutput> => {
let workspaceUsers = await database
const workspaceUsers = await database
.selectFrom('workspace_users')
.where('account_id', '=', account.id)
.selectAll()
.execute();
if (workspaceUsers.length === 0) {
await createDefaultWorkspace(account);
workspaceUsers = await database
.selectFrom('workspace_users')
.where('account_id', '=', account.id)
.selectAll()
.execute();
}
const workspaceOutputs: WorkspaceOutput[] = [];
if (workspaceUsers.length > 0) {
const workspaceIds = workspaceUsers.map((wu) => wu.workspace_id);
@@ -515,6 +456,11 @@ const buildLoginOutput = async (
}
}
if (workspaceOutputs.length === 0) {
const workspace = await workspaceService.createDefaultWorkspace(account);
workspaceOutputs.push(workspace);
}
const deviceId = generateId(IdType.Device);
const { token, salt, hash } = generateToken(deviceId);

View File

@@ -1,6 +1,6 @@
import { database } from '@/data/database';
import { BUCKET_NAMES, filesStorage } from '@/data/storage';
import { hasCollaboratorAccess, hasViewerAccess } from '@/lib/constants';
import { hasCollaboratorAccess } from '@/lib/constants';
import { fetchNodeRole } from '@/lib/nodes';
import { ApiError, ColanodeRequest, ColanodeResponse } from '@/types/api';
import {
@@ -15,13 +15,10 @@ import {
CreateUploadInput,
CreateUploadOutput,
extractFileType,
generateId,
IdType,
UploadMetadata,
} from '@colanode/core';
import { redis } from '@/data/redis';
import { YDoc } from '@colanode/crdt';
import { enqueueEvent } from '@/queues/events';
import { nodeService } from '@/services/node-service';
export const filesRouter = Router();
@@ -336,63 +333,34 @@ filesRouter.put(
});
}
const ydoc = new YDoc(file.id, file.state);
ydoc.updateAttributes({
...file.attributes,
uploadStatus: 'completed',
uploadId: metadata.uploadId,
});
await database
.insertInto('uploads')
.values({
node_id: file.id,
upload_id: uploadId,
workspace_id: workspace.id,
path: metadata.path,
mime_type: metadata.mimeType,
size: metadata.size,
type: extractFileType(metadata.mimeType),
created_by: workspaceUser.id,
created_at: new Date(metadata.createdAt),
completed_at: new Date(),
})
.execute();
const attributes = ydoc.getAttributes();
const state = ydoc.getState();
const fileVersionId = generateId(IdType.Version);
const updatedAt = new Date();
await database.transaction().execute(async (tx) => {
await database
.insertInto('uploads')
.values({
node_id: file.id,
upload_id: uploadId,
workspace_id: workspace.id,
path: metadata.path,
mime_type: metadata.mimeType,
size: metadata.size,
type: extractFileType(metadata.mimeType),
created_by: workspaceUser.id,
created_at: new Date(metadata.createdAt),
completed_at: updatedAt,
})
.execute();
await tx
.updateTable('nodes')
.set({
attributes: JSON.stringify(attributes),
state: state,
updated_at: updatedAt,
updated_by: workspaceUser.id,
version_id: fileVersionId,
server_updated_at: updatedAt,
})
.where('id', '=', file.id)
.execute();
});
await enqueueEvent({
type: 'node_updated',
id: file.id,
await nodeService.updateNode({
nodeId: file.id,
userId: workspaceUser.id,
workspaceId: workspace.id,
beforeAttributes: file.attributes,
afterAttributes: attributes,
updatedBy: workspaceUser.id,
updatedAt: updatedAt.toISOString(),
serverUpdatedAt: updatedAt.toISOString(),
versionId: fileVersionId,
updater: (attributes) => ({
...attributes,
uploadStatus: 'completed',
uploadId: metadata.uploadId,
}),
});
await redis.del(uploadId);
res.status(200).json({ success: true });
}
);

View File

@@ -1,32 +1,17 @@
import { ApiError, ColanodeRequest, ColanodeResponse } from '@/types/api';
import { database, hasUpdateChanges } from '@/data/database';
import { database } from '@/data/database';
import { Router } from 'express';
import { SelectWorkspaceUser } from '@/data/schema';
import {
NodeCreatedEvent,
NodeDeletedEvent,
NodeUpdatedEvent,
} from '@/types/events';
import { enqueueEvent } from '@/queues/events';
import { synapse } from '@/services/synapse';
import { fetchNodeAncestors, mapNode } from '@/lib/nodes';
import {
NodeMutationContext,
registry,
LocalChange,
LocalCreateNodeChangeData,
LocalDeleteNodeChangeData,
LocalUpdateNodeChangeData,
LocalUserNodeChangeData,
SyncChangeResult,
SyncChangesInput,
SyncChangeStatus,
SyncNodeStatesInput,
SyncNodeStatesOutput,
ServerNodeState,
ServerUserNodeState,
LocalNodeTransaction,
SyncNodeTransactionResult,
SyncNodeTransactionsInput,
SyncNodeTransactionStatus,
LocalCreateNodeTransaction,
LocalUpdateNodeTransaction,
LocalDeleteNodeTransaction,
} from '@colanode/core';
import { encodeState, YDoc } from '@colanode/crdt';
import { nodeService } from '@/services/node-service';
export const syncRouter = Router();
@@ -41,7 +26,7 @@ syncRouter.post(
}
const workspaceId = req.params.workspaceId as string;
const input = req.body as SyncChangesInput;
const input = req.body as SyncNodeTransactionsInput;
const workspaceUser = await database
.selectFrom('workspace_users')
@@ -57,18 +42,21 @@ syncRouter.post(
});
}
const results: SyncChangeResult[] = [];
for (const change of input.changes) {
const results: SyncNodeTransactionResult[] = [];
for (const transaction of input.transactions) {
try {
const status = await handleLocalChange(workspaceUser, change);
const status = await handleLocalNodeTransaction(
workspaceUser,
transaction
);
results.push({
id: change.id,
id: transaction.id,
status: status,
});
} catch (error) {
console.error('Error handling local change', error);
console.error('Error handling local transaction', error);
results.push({
id: change.id,
id: transaction.id,
status: 'error',
});
}
@@ -79,356 +67,71 @@ syncRouter.post(
}
);
syncRouter.post(
'/:workspaceId/states',
async (req: ColanodeRequest, res: ColanodeResponse) => {
if (!req.account) {
return res.status(401).json({
code: ApiError.Unauthorized,
message: 'Unauthorized.',
});
}
const workspaceId = req.params.workspaceId as string;
const input = req.body as SyncNodeStatesInput;
const ids = input.ids;
const workspaceUser = await database
.selectFrom('workspace_users')
.selectAll()
.where('workspace_id', '=', workspaceId)
.where('account_id', '=', req.account.id)
.executeTakeFirst();
if (!workspaceUser) {
return res.status(403).json({
code: ApiError.Forbidden,
message: 'Forbidden.',
});
}
const rows = await database
.selectFrom('nodes as n')
.innerJoin('user_nodes as un', 'un.node_id', 'n.id')
.select([
'n.id as node_id',
'n.state as node_state',
'n.created_at as node_created_at',
'n.created_by as node_created_by',
'n.updated_at as node_updated_at',
'n.updated_by as node_updated_by',
'n.version_id as node_version_id',
'un.version_id as user_node_version_id',
'un.last_seen_at as user_node_last_seen_at',
'un.last_seen_version_id as user_node_last_seen_version_id',
'un.mentions_count as user_node_mentions_count',
'un.created_at as user_node_created_at',
'un.updated_at as user_node_updated_at',
])
.where('un.user_id', '=', workspaceUser.id)
.where('n.workspace_id', '=', workspaceId)
.where('n.id', 'in', ids)
.execute();
const states: SyncNodeStatesOutput = {
nodes: {},
};
for (const row of rows) {
const nodeState: ServerNodeState = {
id: row.node_id,
workspaceId: workspaceId,
state: encodeState(row.node_state),
createdAt: row.node_created_at.toISOString(),
createdBy: row.node_created_by,
updatedAt: row.node_updated_at?.toISOString() ?? null,
updatedBy: row.node_updated_by,
serverCreatedAt: new Date().toISOString(),
serverUpdatedAt: row.user_node_updated_at?.toISOString() ?? null,
versionId: row.node_version_id,
};
const userNodeState: ServerUserNodeState = {
nodeId: row.node_id,
userId: workspaceUser.id,
workspaceId: workspaceId,
versionId: row.user_node_version_id,
lastSeenAt: row.user_node_last_seen_at?.toISOString() ?? null,
lastSeenVersionId: row.user_node_last_seen_version_id,
mentionsCount: row.user_node_mentions_count,
createdAt: row.user_node_created_at.toISOString(),
updatedAt: row.user_node_updated_at?.toISOString() ?? null,
};
states.nodes[row.node_id] = {
node: nodeState,
userNode: userNodeState,
};
}
res.status(200).json({ nodes: states });
}
);
const handleLocalChange = async (
const handleLocalNodeTransaction = async (
workspaceUser: SelectWorkspaceUser,
change: LocalChange
): Promise<SyncChangeStatus> => {
switch (change.data.type) {
case 'node_create': {
return handleCreateNodeChange(workspaceUser, change.data);
}
case 'node_update': {
return handleUpdateNodeChange(workspaceUser, change.data);
}
case 'node_delete': {
return handleDeleteNodeChange(workspaceUser, change.data);
}
case 'user_node_update': {
return handleUserNodeStateChange(workspaceUser, change.data);
}
default: {
return 'error';
}
transaction: LocalNodeTransaction
): Promise<SyncNodeTransactionStatus> => {
if (transaction.type === 'create') {
return await handleCreateNodeTransaction(workspaceUser, transaction);
} else if (transaction.type === 'update') {
return await handleUpdateNodeTransaction(workspaceUser, transaction);
} else if (transaction.type === 'delete') {
return await handleDeleteNodeTransaction(workspaceUser, transaction);
} else {
return 'error';
}
};
const handleCreateNodeChange = async (
const handleCreateNodeTransaction = async (
workspaceUser: SelectWorkspaceUser,
changeData: LocalCreateNodeChangeData
): Promise<SyncChangeStatus> => {
const existingNode = await database
.selectFrom('nodes')
.where('id', '=', changeData.id)
.executeTakeFirst();
if (existingNode) {
return 'success';
}
const ydoc = new YDoc(changeData.id, changeData.state);
const attributes = ydoc.getAttributes();
const model = registry.getModel(attributes.type);
if (!model.schema.safeParse(attributes).success) {
return 'error';
}
const ancestorRows = attributes.parentId
? await fetchNodeAncestors(attributes.parentId)
: [];
const ancestors = ancestorRows.map(mapNode);
const context = new NodeMutationContext(
workspaceUser.account_id,
workspaceUser.workspace_id,
changeData.createdBy,
workspaceUser.role,
ancestors
);
if (!model.canCreate(context, attributes)) {
return 'error';
}
await database
.insertInto('nodes')
.values({
id: changeData.id,
attributes: JSON.stringify(attributes),
workspace_id: workspaceUser.workspace_id,
state: ydoc.getState(),
created_at: new Date(changeData.createdAt),
created_by: changeData.createdBy,
version_id: changeData.versionId,
server_created_at: new Date(),
})
.execute();
const event: NodeCreatedEvent = {
type: 'node_created',
id: changeData.id,
workspaceId: workspaceUser.workspace_id,
attributes: attributes,
createdBy: changeData.createdBy,
createdAt: changeData.createdAt,
serverCreatedAt: new Date().toISOString(),
versionId: changeData.versionId,
};
await enqueueEvent(event);
return 'success';
};
const handleUpdateNodeChange = async (
workspaceUser: SelectWorkspaceUser,
changeData: LocalUpdateNodeChangeData
): Promise<SyncChangeStatus> => {
let count = 0;
while (count++ < 10) {
const existingNode = await database
.selectFrom('nodes')
.selectAll()
.where('id', '=', changeData.id)
.executeTakeFirst();
if (
!existingNode ||
existingNode.workspace_id != workspaceUser.workspace_id
) {
return 'error';
}
const ydoc = new YDoc(changeData.id, existingNode.state);
for (const update of changeData.updates) {
ydoc.applyUpdate(update);
}
const attributes = ydoc.getAttributes();
const attributesJson = JSON.stringify(attributes);
const state = ydoc.getState();
const model = registry.getModel(attributes.type);
if (!model.schema.safeParse(attributes).success) {
return 'error';
}
const ancestorRows = await fetchNodeAncestors(existingNode.id);
const ancestors = ancestorRows.map(mapNode);
const node = ancestors.find((ancestor) => ancestor.id === existingNode.id);
if (!node) {
return 'error';
}
const context = new NodeMutationContext(
workspaceUser.account_id,
workspaceUser.workspace_id,
changeData.updatedBy,
workspaceUser.role,
ancestors
);
if (!model.canUpdate(context, node, attributes)) {
return 'error';
}
const result = await database
.updateTable('nodes')
.set({
attributes: attributesJson,
state: state,
updated_at: new Date(changeData.updatedAt),
updated_by: changeData.updatedBy,
version_id: changeData.versionId,
server_updated_at: new Date(),
})
.where('id', '=', changeData.id)
.where('version_id', '=', existingNode.version_id)
.execute();
if (hasUpdateChanges(result)) {
const event: NodeUpdatedEvent = {
type: 'node_updated',
id: changeData.id,
workspaceId: workspaceUser.workspace_id,
beforeAttributes: existingNode.attributes,
afterAttributes: attributes,
updatedBy: changeData.updatedBy,
updatedAt: changeData.updatedAt,
serverUpdatedAt: new Date().toISOString(),
versionId: changeData.versionId,
};
await enqueueEvent(event);
return 'success';
}
}
return 'error';
};
const handleDeleteNodeChange = async (
workspaceUser: SelectWorkspaceUser,
changeData: LocalDeleteNodeChangeData
): Promise<SyncChangeStatus> => {
const existingNode = await database
.selectFrom('nodes')
.where('id', '=', changeData.id)
.selectAll()
.executeTakeFirst();
if (!existingNode) {
return 'success';
}
if (existingNode.workspace_id !== workspaceUser.workspace_id) {
return '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);
if (!node) {
return 'error';
}
const context = new NodeMutationContext(
workspaceUser.account_id,
workspaceUser.workspace_id,
changeData.deletedBy,
workspaceUser.role,
ancestors
);
if (!model.canDelete(context, node)) {
return 'error';
}
await database.deleteFrom('nodes').where('id', '=', changeData.id).execute();
const event: NodeDeletedEvent = {
type: 'node_deleted',
id: changeData.id,
workspaceId: workspaceUser.workspace_id,
attributes: existingNode.attributes,
deletedAt: new Date().toISOString(),
};
await enqueueEvent(event);
return 'success';
};
const handleUserNodeStateChange = async (
workspaceUser: SelectWorkspaceUser,
changeData: LocalUserNodeChangeData
): Promise<SyncChangeStatus> => {
if (workspaceUser.id !== changeData.userId) {
return 'error';
}
await database
.updateTable('user_nodes')
.set({
last_seen_version_id: changeData.lastSeenVersionId,
last_seen_at: new Date(changeData.lastSeenAt),
mentions_count: changeData.mentionsCount,
version_id: changeData.versionId,
updated_at: new Date(changeData.lastSeenAt),
})
.where('node_id', '=', changeData.nodeId)
.where('user_id', '=', changeData.userId)
.execute();
await synapse.sendSynapseMessage({
type: 'user_node_update',
nodeId: changeData.nodeId,
userId: changeData.userId,
workspaceId: workspaceUser.workspace_id,
transaction: LocalCreateNodeTransaction
): Promise<SyncNodeTransactionStatus> => {
const output = await nodeService.applyNodeCreateTransaction(workspaceUser, {
id: transaction.id,
nodeId: transaction.nodeId,
data: transaction.data,
createdAt: new Date(transaction.createdAt),
});
if (!output) {
return 'error';
}
return 'success';
};
const handleUpdateNodeTransaction = async (
workspaceUser: SelectWorkspaceUser,
transaction: LocalUpdateNodeTransaction
): Promise<SyncNodeTransactionStatus> => {
const output = await nodeService.applyNodeUpdateTransaction(workspaceUser, {
id: transaction.id,
nodeId: transaction.nodeId,
userId: transaction.createdBy,
data: transaction.data,
createdAt: new Date(transaction.createdAt),
});
if (!output) {
return 'error';
}
return 'success';
};
const handleDeleteNodeTransaction = async (
workspaceUser: SelectWorkspaceUser,
transaction: LocalDeleteNodeTransaction
): Promise<SyncNodeTransactionStatus> => {
const output = await nodeService.applyNodeDeleteTransaction(workspaceUser, {
id: transaction.id,
nodeId: transaction.nodeId,
createdAt: new Date(transaction.createdAt),
});
if (!output) {
return 'error';
}
return 'success';
};

View File

@@ -4,33 +4,18 @@ import {
WorkspaceCreateInput,
WorkspaceOutput,
WorkspaceRole,
WorkspaceStatus,
WorkspaceUserStatus,
WorkspaceUpdateInput,
NodeOutput,
WorkspaceUserInviteResult,
} from '@colanode/core';
import { ApiError, ColanodeRequest, ColanodeResponse } from '@/types/api';
import {
generateId,
IdType,
UserAttributes,
WorkspaceAttributes,
AccountStatus,
} from '@colanode/core';
import { generateId, IdType, AccountStatus } from '@colanode/core';
import { database } from '@/data/database';
import { Router } from 'express';
import {
CreateAccount,
CreateNode,
CreateWorkspaceUser,
SelectNode,
SelectWorkspaceUser,
} from '@/data/schema';
import { getNameFromEmail } from '@/lib/utils';
import { mapNodeOutput } from '@/lib/nodes';
import { NodeCreatedEvent, NodeUpdatedEvent } from '@/types/events';
import { enqueueEvent } from '@/queues/events';
import { YDoc } from '@colanode/crdt';
import { fetchNode, mapNode, mapNodeTransaction } from '@/lib/nodes';
import { nodeService } from '@/services/node-service';
import { workspaceService } from '@/services/workspace-service';
export const workspacesRouter = Router();
@@ -66,135 +51,7 @@ 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 YDoc(userId);
const userAttributes: UserAttributes = {
type: 'user',
name: account.name,
avatar: account.avatar,
email: account.email,
accountId: account.id,
role: 'owner',
parentId: workspaceId,
};
userDoc.updateAttributes(userAttributes);
await database.transaction().execute(async (trx) => {
await trx
.insertInto('workspaces')
.values({
id: workspaceId,
name: input.name,
description: input.description,
avatar: input.avatar,
created_at: createdAt,
created_by: account.id,
status: WorkspaceStatus.Active,
version_id: workspaceVersionId,
})
.execute();
await trx
.insertInto('workspace_users')
.values({
id: userId,
account_id: account.id,
workspace_id: workspaceId,
role: 'owner',
created_at: createdAt,
created_by: account.id,
status: WorkspaceUserStatus.Active,
version_id: generateId(IdType.Version),
})
.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: JSON.stringify(userAttributes),
state: userDoc.getState(),
created_at: createdAt,
created_by: account.id,
version_id: userVersionId,
server_created_at: createdAt,
})
.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: userAttributes,
createdBy: account.id,
createdAt: createdAt.toISOString(),
versionId: userVersionId,
serverCreatedAt: createdAt.toISOString(),
};
await enqueueEvent(userEvent);
const output: WorkspaceOutput = {
id: workspaceId,
name: input.name,
description: input.description,
avatar: input.avatar,
versionId: workspaceVersionId,
user: {
id: userId,
accountId: account.id,
role: 'owner',
},
};
const output = await workspaceService.createWorkspace(account, input);
return res.status(200).json(output);
}
);
@@ -266,6 +123,23 @@ workspacesRouter.put(
});
}
await nodeService.updateNode({
nodeId: updatedWorkspace.id,
userId: req.account.id,
workspaceId: id,
updater: (attributes) => {
if (attributes.type !== 'workspace') {
return null;
}
attributes.name = input.name;
attributes.description = input.description;
attributes.avatar = input.avatar;
return attributes;
},
});
const output: WorkspaceOutput = {
id: updatedWorkspace.id,
name: updatedWorkspace.name,
@@ -500,181 +374,110 @@ workspacesRouter.post(
});
}
const existingAccounts = await database
.selectFrom('accounts')
.selectAll()
.where('email', 'in', input.emails)
.execute();
let existingWorkspaceUsers: SelectWorkspaceUser[] = [];
let existingUsers: SelectNode[] = [];
if (existingAccounts.length > 0) {
const existingAccountIds = existingAccounts.map((account) => account.id);
existingWorkspaceUsers = await database
.selectFrom('workspace_users')
.selectAll()
.where((eb) =>
eb.and([
eb('account_id', 'in', existingAccountIds),
eb('workspace_id', '=', workspace.id),
])
)
.execute();
const workspaceNodeRow = await fetchNode(workspace.id);
if (!workspaceNodeRow) {
return res.status(500).json({
code: ApiError.InternalServerError,
message: 'Something went wrong.',
});
}
if (existingWorkspaceUsers.length > 0) {
const existingUserIds = existingWorkspaceUsers.map(
(workspaceAccount) => workspaceAccount.id
);
existingUsers = await database
.selectFrom('nodes')
.selectAll()
.where('id', 'in', existingUserIds)
.execute();
}
const accountsToCreate: CreateAccount[] = [];
const workspaceUsersToCreate: CreateWorkspaceUser[] = [];
const usersToCreate: CreateNode[] = [];
const users: NodeOutput[] = [];
const workspaceNode = mapNode(workspaceNodeRow);
const results: WorkspaceUserInviteResult[] = [];
for (const email of input.emails) {
let account = existingAccounts.find((account) => account.email === email);
let account = await database
.selectFrom('accounts')
.select(['id', 'name', 'email', 'avatar'])
.where('email', '=', email)
.executeTakeFirst();
if (!account) {
account = {
id: generateId(IdType.Account),
name: getNameFromEmail(email),
email: email,
avatar: null,
attrs: null,
password: null,
status: AccountStatus.Pending,
created_at: new Date(),
updated_at: null,
};
accountsToCreate.push({
id: account.id,
email: account.email,
name: account.name,
status: account.status,
created_at: account.created_at,
});
account = await database
.insertInto('accounts')
.returning(['id', 'name', 'email', 'avatar'])
.values({
id: generateId(IdType.Account),
name: getNameFromEmail(email),
email: email,
avatar: null,
attrs: null,
password: null,
status: AccountStatus.Pending,
created_at: new Date(),
updated_at: null,
})
.executeTakeFirst();
}
const existingWorkspaceUser = existingWorkspaceUsers.find(
(workspaceUser) => workspaceUser.account_id === account!.id
);
if (!account) {
results.push({
email: email,
result: 'error',
});
continue;
}
const existingWorkspaceUser = await database
.selectFrom('workspace_users')
.selectAll()
.where('account_id', '=', account.id)
.where('workspace_id', '=', id)
.executeTakeFirst();
if (existingWorkspaceUser) {
const existingUser = existingUsers.find(
(user) => user.id === existingWorkspaceUser.id
);
if (!existingUser) {
return res.status(500).json({
code: ApiError.InternalServerError,
message: 'Something went wrong.',
});
}
users.push(mapNodeOutput(existingUser));
results.push({
email: email,
result: 'exists',
});
continue;
}
const userId = generateId(IdType.User);
const userVersionId = generateId(IdType.Version);
const userDoc = new YDoc(userId);
const newWorkspaceUser = await database
.insertInto('workspace_users')
.returningAll()
.values({
id: userId,
account_id: account.id,
workspace_id: id,
role: input.role,
created_at: new Date(),
created_by: req.account.id,
status: WorkspaceUserStatus.Active,
version_id: generateId(IdType.Version),
})
.executeTakeFirst();
const userAttributes: UserAttributes = {
type: 'user',
name: account!.name,
avatar: account!.avatar,
email: account!.email,
role: input.role,
accountId: account!.id,
parentId: workspace.id,
};
userDoc.updateAttributes(userAttributes);
if (!newWorkspaceUser) {
results.push({
email: email,
result: 'error',
});
}
workspaceUsersToCreate.push({
id: userId,
account_id: account!.id,
workspace_id: workspace.id,
role: input.role,
created_at: new Date(),
created_by: req.account.id,
status: WorkspaceUserStatus.Active,
version_id: userVersionId,
await nodeService.createNode({
nodeId: userId,
attributes: {
type: 'user',
name: account.name,
email: account.email,
role: input.role,
accountId: account.id,
parentId: id,
},
userId: workspaceUser.id,
workspaceId: id,
ancestors: [workspaceNode],
});
const user: NodeOutput = {
id: userId,
type: 'user',
parentId: workspace.id,
attributes: userAttributes,
state: userDoc.getEncodedState(),
createdAt: new Date().toISOString(),
createdBy: workspaceUser.id,
serverCreatedAt: new Date().toISOString(),
versionId: userVersionId,
workspaceId: workspace.id,
};
usersToCreate.push({
id: user.id,
attributes: JSON.stringify(userAttributes),
state: userDoc.getState(),
created_at: new Date(user.createdAt),
created_by: user.createdBy,
server_created_at: new Date(user.serverCreatedAt),
version_id: user.versionId,
workspace_id: user.workspaceId,
});
users.push(user);
}
if (
accountsToCreate.length > 0 ||
workspaceUsersToCreate.length > 0 ||
usersToCreate.length > 0
) {
await database.transaction().execute(async (trx) => {
if (accountsToCreate.length > 0) {
await trx.insertInto('accounts').values(accountsToCreate).execute();
}
if (workspaceUsersToCreate.length > 0) {
await trx
.insertInto('workspace_users')
.values(workspaceUsersToCreate)
.execute();
}
if (usersToCreate.length > 0) {
await trx.insertInto('nodes').values(usersToCreate).execute();
for (const user of usersToCreate) {
const userEvent: NodeCreatedEvent = {
type: 'node_created',
id: user.id,
workspaceId: user.workspace_id,
attributes: JSON.parse(user.attributes ?? '{}'),
createdBy: user.created_by,
createdAt: user.created_at.toISOString(),
serverCreatedAt: user.server_created_at.toISOString(),
versionId: user.version_id,
};
await enqueueEvent(userEvent);
}
}
results.push({
email: email,
result: 'success',
});
}
return res.status(200).json({
users: users,
results: results,
});
}
);
@@ -756,79 +559,40 @@ workspacesRouter.put(
});
}
const attributes = user.attributes;
if (attributes.type !== 'user') {
return res.status(400).json({
code: ApiError.BadRequest,
message: 'BadRequest.',
await database
.updateTable('workspace_users')
.set({
role: input.role,
updated_at: new Date(),
updated_by: currentWorkspaceUser.account_id,
version_id: generateId(IdType.Version),
})
.where('id', '=', userId)
.execute();
const updateUserOutput = await nodeService.updateNode({
nodeId: user.id,
userId: currentWorkspaceUser.account_id,
workspaceId: workspace.id,
updater: (attributes) => {
if (attributes.type !== 'user') {
return null;
}
attributes.role = input.role;
return attributes;
},
});
if (!updateUserOutput) {
return res.status(500).json({
code: ApiError.InternalServerError,
message: 'Something went wrong.',
});
}
const updatedAt = new Date();
const userDoc = new YDoc(user.id, user.state);
userDoc.updateAttributes({
...attributes,
role: input.role,
});
const userNode: NodeOutput = {
id: user.id,
type: user.type,
workspaceId: user.workspace_id,
parentId: workspace.id,
attributes: userDoc.getAttributes(),
state: userDoc.getEncodedState(),
createdAt: user.created_at.toISOString(),
createdBy: user.created_by,
serverCreatedAt: user.server_created_at.toISOString(),
serverUpdatedAt: updatedAt.toISOString(),
versionId: generateId(IdType.Version),
updatedAt: updatedAt.toISOString(),
updatedBy: currentWorkspaceUser.id,
};
await database.transaction().execute(async (trx) => {
await trx
.updateTable('workspace_users')
.set({
role: input.role,
updated_at: new Date(),
updated_by: currentWorkspaceUser.account_id,
version_id: generateId(IdType.Version),
})
.where('id', '=', userId)
.execute();
await trx
.updateTable('nodes')
.set({
attributes: JSON.stringify(userDoc.getAttributes()),
state: userDoc.getState(),
server_updated_at: updatedAt,
updated_at: updatedAt,
updated_by: currentWorkspaceUser.id,
version_id: userNode.versionId,
})
.where('id', '=', userNode.id)
.execute();
});
const event: NodeUpdatedEvent = {
type: 'node_updated',
id: userNode.id,
workspaceId: userNode.workspaceId,
beforeAttributes: attributes,
afterAttributes: userDoc.getAttributes(),
updatedBy: currentWorkspaceUser.id,
updatedAt: updatedAt.toISOString(),
serverUpdatedAt: updatedAt.toISOString(),
versionId: userNode.versionId,
};
await enqueueEvent(event);
return res.status(200).json({
user: userNode,
transaction: mapNodeTransaction(updateUserOutput.transaction),
});
}
);

View File

@@ -0,0 +1,883 @@
import {
extractNodeCollaborators,
generateId,
IdType,
Node,
NodeAttributes,
NodeMutationContext,
registry,
} from '@colanode/core';
import { decodeState, YDoc } from '@colanode/crdt';
import {
CreateCollaboration,
CreateNode,
CreateNodeTransaction,
SelectCollaboration,
SelectWorkspaceUser,
} from '@/data/schema';
import { cloneDeep, difference } from 'lodash-es';
import { fetchWorkspaceUsers, mapNode } from '@/lib/nodes';
import { database } from '@/data/database';
import { fetchNodeAncestors } from '@/lib/nodes';
import {
ApplyNodeCreateTransactionInput,
ApplyNodeCreateTransactionOutput,
ApplyNodeDeleteTransactionInput,
ApplyNodeDeleteTransactionOutput,
ApplyNodeUpdateTransactionInput,
ApplyNodeUpdateTransactionOutput,
CreateNodeInput,
CreateNodeOutput,
UpdateNodeInput,
UpdateNodeOutput,
} from '@/types/nodes';
import { buildDefaultCollaboration } from '@/lib/collaborations';
import { eventBus } from '@/lib/event-bus';
import { logService } from './log';
const UPDATE_RETRIES_LIMIT = 10;
type UpdateResult<T> = {
type: 'success' | 'error' | 'retry';
output: T | null;
};
type CollaboratorChangeResult = {
removedCollaborators: string[];
addedCollaborators: string[];
};
class NodeService {
private readonly logger = logService.createLogger('node-service');
public async createNode(
input: CreateNodeInput
): Promise<CreateNodeOutput | null> {
const model = registry.getNodeModel(input.attributes.type);
const ydoc = new YDoc();
const update = ydoc.updateAttributes(model.schema, input.attributes);
const attributesJson = JSON.stringify(ydoc.getAttributes<NodeAttributes>());
const date = new Date();
const transactionId = generateId(IdType.Transaction);
const createNode: CreateNode = {
id: input.nodeId,
workspace_id: input.workspaceId,
attributes: attributesJson,
created_at: date,
created_by: input.userId,
transaction_id: transactionId,
};
const createTransaction: CreateNodeTransaction = {
id: transactionId,
node_id: input.nodeId,
workspace_id: input.workspaceId,
type: 'create',
data: update,
created_at: date,
created_by: input.userId,
server_created_at: date,
};
const createCollaborations = await this.buildCollaborations(
input.nodeId,
input.workspaceId,
input.attributes,
input.ancestors
);
try {
const { createdNode, createdTransaction, createdCollaborations } =
await this.applyDatabaseCreateTransaction(
createNode,
createTransaction,
createCollaborations
);
eventBus.publish({
type: 'node_transaction_created',
transactionId: transactionId,
nodeId: input.nodeId,
workspaceId: input.workspaceId,
});
for (const collaboration of createdCollaborations) {
eventBus.publish({
type: 'collaboration_created',
userId: collaboration.user_id,
nodeId: collaboration.node_id,
workspaceId: collaboration.workspace_id,
});
}
return {
node: createdNode,
transaction: createdTransaction,
createdCollaborations: createdCollaborations,
};
} catch (error) {
this.logger.error(error, 'Failed to create node transaction');
return null;
}
}
public async updateNode(
input: UpdateNodeInput
): Promise<UpdateNodeOutput | null> {
let count = 0;
while (count < UPDATE_RETRIES_LIMIT) {
const result = await this.tryUpdateNode(input);
if (result.type === 'success') {
return result.output;
}
if (result.type === 'error') {
return null;
}
count++;
}
return null;
}
private async tryUpdateNode(
input: UpdateNodeInput
): Promise<UpdateResult<UpdateNodeOutput>> {
const ancestorRows = await fetchNodeAncestors(input.nodeId);
const ancestors = ancestorRows.map(mapNode);
const node = ancestors.find((ancestor) => ancestor.id === input.nodeId);
if (!node) {
return { type: 'error', output: null };
}
const previousTransactions = await database
.selectFrom('node_transactions')
.selectAll()
.where('node_id', '=', input.nodeId)
.orderBy('id', 'asc')
.execute();
const ydoc = new YDoc();
for (const transaction of previousTransactions) {
if (transaction.data === null) {
return { type: 'error', output: null };
}
ydoc.applyUpdate(transaction.data);
}
const currentAttributes = ydoc.getAttributes<NodeAttributes>();
const updatedAttributes = input.updater(cloneDeep(currentAttributes));
if (!updatedAttributes) {
return { type: 'error', output: null };
}
const model = registry.getNodeModel(updatedAttributes.type);
const update = ydoc.updateAttributes(model.schema, updatedAttributes);
const attributes = ydoc.getAttributes<NodeAttributes>();
const attributesJson = JSON.stringify(attributes);
const date = new Date();
const transactionId = generateId(IdType.Transaction);
const { removedCollaborators, addedCollaborators } =
this.checkCollaboratorChanges(
node,
ancestors.filter((a) => a.id !== input.nodeId),
attributes
);
const collaborations: CreateCollaboration[] = addedCollaborators.map(
(collaboratorId) =>
buildDefaultCollaboration(
collaboratorId,
input.nodeId,
attributes.type,
input.workspaceId
)
);
try {
const {
updatedNode,
createdTransaction,
createdCollaborations,
updatedCollaborations,
} = await database.transaction().execute(async (trx) => {
const updatedNode = await trx
.updateTable('nodes')
.returningAll()
.set({
attributes: attributesJson,
updated_at: date,
updated_by: input.userId,
transaction_id: transactionId,
})
.where('id', '=', input.nodeId)
.where('transaction_id', '=', node.transactionId)
.executeTakeFirst();
if (!updatedNode) {
throw new Error('Failed to update node');
}
const createdTransaction = await trx
.insertInto('node_transactions')
.returningAll()
.values({
id: transactionId,
node_id: input.nodeId,
workspace_id: input.workspaceId,
type: 'update',
data: update,
created_at: date,
created_by: input.userId,
server_created_at: date,
})
.executeTakeFirst();
if (!createdTransaction) {
throw new Error('Failed to create transaction');
}
let createdCollaborations: SelectCollaboration[] = [];
let updatedCollaborations: SelectCollaboration[] = [];
if (collaborations.length > 0) {
createdCollaborations = await trx
.insertInto('collaborations')
.returningAll()
.values(collaborations)
.execute();
if (createdCollaborations.length !== collaborations.length) {
throw new Error('Failed to create collaborations');
}
}
if (removedCollaborators.length > 0) {
updatedCollaborations = await trx
.updateTable('collaborations')
.returningAll()
.set({
deleted_at: date,
})
.where('node_id', '=', input.nodeId)
.where('user_id', 'in', removedCollaborators)
.execute();
if (updatedCollaborations.length !== removedCollaborators.length) {
throw new Error('Failed to remove collaborations');
}
}
return {
updatedNode,
createdTransaction,
createdCollaborations,
updatedCollaborations,
};
});
eventBus.publish({
type: 'node_transaction_created',
transactionId: transactionId,
nodeId: input.nodeId,
workspaceId: input.workspaceId,
});
for (const collaboration of createdCollaborations) {
eventBus.publish({
type: 'collaboration_created',
userId: collaboration.user_id,
nodeId: collaboration.node_id,
workspaceId: collaboration.workspace_id,
});
}
for (const collaboration of updatedCollaborations) {
eventBus.publish({
type: 'collaboration_updated',
userId: collaboration.user_id,
nodeId: collaboration.node_id,
workspaceId: collaboration.workspace_id,
});
}
return {
type: 'success',
output: {
node: updatedNode,
transaction: createdTransaction,
createdCollaborations,
updatedCollaborations,
},
};
} catch (error) {
return { type: 'retry', output: null };
}
}
public async applyNodeCreateTransaction(
workspaceUser: SelectWorkspaceUser,
input: ApplyNodeCreateTransactionInput
): Promise<ApplyNodeCreateTransactionOutput | null> {
const ydoc = new YDoc();
ydoc.applyUpdate(input.data);
const attributes = ydoc.getAttributes<NodeAttributes>();
const ancestorRows = attributes.parentId
? await fetchNodeAncestors(attributes.parentId)
: [];
const ancestors = ancestorRows.map(mapNode);
const context = new NodeMutationContext(
workspaceUser.account_id,
workspaceUser.workspace_id,
workspaceUser.id,
workspaceUser.role,
ancestors
);
const model = registry.getNodeModel(attributes.type);
if (!model.schema.safeParse(attributes).success) {
return null;
}
if (!model.canCreate(context, attributes)) {
return null;
}
const createNode: CreateNode = {
id: input.nodeId,
attributes: JSON.stringify(attributes),
workspace_id: context.workspaceId,
created_at: input.createdAt,
created_by: context.userId,
transaction_id: input.id,
};
const createTransaction: CreateNodeTransaction = {
id: input.id,
node_id: input.nodeId,
workspace_id: context.workspaceId,
type: 'create',
data:
typeof input.data === 'string' ? decodeState(input.data) : input.data,
created_at: input.createdAt,
created_by: context.userId,
server_created_at: new Date(),
};
const createCollaborations: CreateCollaboration[] =
await this.buildCollaborations(
input.nodeId,
context.workspaceId,
attributes,
ancestors
);
try {
const { createdNode, createdTransaction, createdCollaborations } =
await this.applyDatabaseCreateTransaction(
createNode,
createTransaction,
createCollaborations
);
eventBus.publish({
type: 'node_transaction_created',
transactionId: input.id,
nodeId: input.nodeId,
workspaceId: context.workspaceId,
});
for (const collaboration of createdCollaborations) {
eventBus.publish({
type: 'collaboration_created',
userId: collaboration.user_id,
nodeId: collaboration.node_id,
workspaceId: collaboration.workspace_id,
});
}
return {
node: createdNode,
transaction: createdTransaction,
collaborations: createdCollaborations,
};
} catch (error) {
this.logger.error(error, 'Failed to apply node create transaction');
return null;
}
}
public async applyNodeUpdateTransaction(
workspaceUser: SelectWorkspaceUser,
input: ApplyNodeUpdateTransactionInput
): Promise<ApplyNodeUpdateTransactionOutput | null> {
let count = 0;
while (count < UPDATE_RETRIES_LIMIT) {
const result = await this.tryApplyNodeUpdateTransaction(
workspaceUser,
input
);
if (result.type === 'success') {
return result.output;
}
if (result.type === 'error') {
return null;
}
count++;
}
return null;
}
private async tryApplyNodeUpdateTransaction(
workspaceUser: SelectWorkspaceUser,
input: ApplyNodeUpdateTransactionInput
): Promise<UpdateResult<ApplyNodeUpdateTransactionOutput>> {
const ancestorRows = await fetchNodeAncestors(input.nodeId);
const ancestors = ancestorRows.map(mapNode);
const node = ancestors.find((ancestor) => ancestor.id === input.nodeId);
if (!node) {
return { type: 'error', output: null };
}
const previousTransactions = await database
.selectFrom('node_transactions')
.selectAll()
.where('node_id', '=', input.nodeId)
.orderBy('number', 'asc')
.execute();
const ydoc = new YDoc();
for (const previousTransaction of previousTransactions) {
if (previousTransaction.data === null) {
return { type: 'error', output: null };
}
ydoc.applyUpdate(previousTransaction.data);
}
ydoc.applyUpdate(input.data);
const attributes = ydoc.getAttributes<NodeAttributes>();
const attributesJson = JSON.stringify(attributes);
const model = registry.getNodeModel(attributes.type);
if (!model.schema.safeParse(attributes).success) {
return { type: 'error', output: null };
}
const context = new NodeMutationContext(
workspaceUser.account_id,
workspaceUser.workspace_id,
workspaceUser.id,
workspaceUser.role,
ancestors
);
if (!model.canUpdate(context, node, attributes)) {
return { type: 'error', output: null };
}
const { removedCollaborators, addedCollaborators } =
this.checkCollaboratorChanges(
node,
ancestors.filter((a) => a.id !== input.nodeId),
attributes
);
const collaborations: CreateCollaboration[] = addedCollaborators.map(
(collaboratorId) =>
buildDefaultCollaboration(
collaboratorId,
input.nodeId,
attributes.type,
context.workspaceId
)
);
try {
const {
updatedNode,
createdTransaction,
createdCollaborations,
updatedCollaborations,
} = await database.transaction().execute(async (trx) => {
const updatedNode = await trx
.updateTable('nodes')
.returningAll()
.set({
attributes: attributesJson,
updated_at: input.createdAt,
updated_by: input.userId,
transaction_id: input.id,
})
.where('id', '=', input.nodeId)
.where('transaction_id', '=', node.transactionId)
.executeTakeFirst();
if (!updatedNode) {
throw new Error('Failed to update node');
}
const createdTransaction = await trx
.insertInto('node_transactions')
.returningAll()
.values({
id: input.id,
node_id: input.nodeId,
workspace_id: context.workspaceId,
type: 'update',
data:
typeof input.data === 'string'
? decodeState(input.data)
: input.data,
created_at: input.createdAt,
created_by: input.userId,
server_created_at: new Date(),
})
.executeTakeFirst();
if (!createdTransaction) {
throw new Error('Failed to create transaction');
}
let createdCollaborations: SelectCollaboration[] = [];
let updatedCollaborations: SelectCollaboration[] = [];
if (collaborations.length > 0) {
createdCollaborations = await trx
.insertInto('collaborations')
.returningAll()
.values(collaborations)
.execute();
if (createdCollaborations.length !== collaborations.length) {
throw new Error('Failed to create collaborations');
}
}
if (removedCollaborators.length > 0) {
updatedCollaborations = await trx
.updateTable('collaborations')
.returningAll()
.set({
deleted_at: input.createdAt,
})
.where('node_id', '=', input.nodeId)
.where('user_id', 'in', removedCollaborators)
.execute();
if (updatedCollaborations.length !== removedCollaborators.length) {
throw new Error('Failed to remove collaborations');
}
}
return {
updatedNode,
createdTransaction,
createdCollaborations,
updatedCollaborations,
};
});
eventBus.publish({
type: 'node_transaction_created',
transactionId: input.id,
nodeId: input.nodeId,
workspaceId: context.workspaceId,
});
for (const collaboration of createdCollaborations) {
eventBus.publish({
type: 'collaboration_created',
userId: collaboration.user_id,
nodeId: collaboration.node_id,
workspaceId: collaboration.workspace_id,
});
}
for (const collaboration of updatedCollaborations) {
eventBus.publish({
type: 'collaboration_updated',
userId: collaboration.user_id,
nodeId: collaboration.node_id,
workspaceId: collaboration.workspace_id,
});
}
return {
type: 'success',
output: {
node: updatedNode,
transaction: createdTransaction,
createdCollaborations,
updatedCollaborations,
},
};
} catch (error) {
return { type: 'retry', output: null };
}
}
public async applyNodeDeleteTransaction(
workspaceUser: SelectWorkspaceUser,
input: ApplyNodeDeleteTransactionInput
): Promise<ApplyNodeDeleteTransactionOutput | null> {
const ancestorRows = await fetchNodeAncestors(input.nodeId);
const ancestors = ancestorRows.map(mapNode);
const node = ancestors.find((ancestor) => ancestor.id === input.nodeId);
if (!node) {
return null;
}
const model = registry.getNodeModel(node.attributes.type);
const context = new NodeMutationContext(
workspaceUser.account_id,
workspaceUser.workspace_id,
workspaceUser.id,
workspaceUser.role,
ancestors
);
if (!model.canDelete(context, node)) {
return null;
}
const { deletedNode, createdTransaction, updatedCollaborations } =
await database.transaction().execute(async (trx) => {
const deletedNode = await trx
.deleteFrom('nodes')
.returningAll()
.where('id', '=', input.nodeId)
.executeTakeFirst();
if (!deletedNode) {
throw new Error('Failed to delete node');
}
await trx
.deleteFrom('node_transactions')
.where('node_id', '=', input.nodeId)
.execute();
const createdTransaction = await trx
.insertInto('node_transactions')
.returningAll()
.values({
id: input.id,
node_id: input.nodeId,
workspace_id: workspaceUser.workspace_id,
type: 'delete',
created_at: input.createdAt,
created_by: workspaceUser.id,
server_created_at: new Date(),
})
.executeTakeFirst();
if (!createdTransaction) {
throw new Error('Failed to create transaction');
}
const updatedCollaborations = await trx
.updateTable('collaborations')
.returningAll()
.set({
deleted_at: input.createdAt,
})
.where('node_id', '=', input.nodeId)
.execute();
return {
deletedNode,
createdTransaction,
updatedCollaborations,
};
});
eventBus.publish({
type: 'node_transaction_created',
transactionId: input.id,
nodeId: input.nodeId,
workspaceId: workspaceUser.workspace_id,
});
for (const collaboration of updatedCollaborations) {
eventBus.publish({
type: 'collaboration_updated',
userId: collaboration.user_id,
nodeId: collaboration.node_id,
workspaceId: collaboration.workspace_id,
});
}
return {
node: deletedNode,
transaction: createdTransaction,
updatedCollaborations,
};
}
private async applyDatabaseCreateTransaction(
node: CreateNode,
transaction: CreateNodeTransaction,
collaborations: CreateCollaboration[]
) {
return await database.transaction().execute(async (trx) => {
const createdNode = await trx
.insertInto('nodes')
.returningAll()
.values(node)
.executeTakeFirst();
if (!createdNode) {
throw new Error('Failed to create node');
}
const createdTransaction = await trx
.insertInto('node_transactions')
.returningAll()
.values(transaction)
.executeTakeFirst();
if (!createdTransaction) {
throw new Error('Failed to create transaction');
}
let createdCollaborations: SelectCollaboration[] = [];
if (collaborations.length > 0) {
createdCollaborations = await trx
.insertInto('collaborations')
.returningAll()
.values(collaborations)
.execute();
if (createdCollaborations.length !== collaborations.length) {
throw new Error('Failed to create collaborations');
}
}
return { createdNode, createdTransaction, createdCollaborations };
});
}
private async buildCollaborations(
nodeId: string,
workspaceId: string,
attributes: NodeAttributes,
ancestors: Node[]
) {
if (attributes.type === 'user') {
return this.buildUserCollaborations(nodeId, workspaceId);
}
return this.buildNodeCollaborations(
nodeId,
workspaceId,
attributes,
ancestors
);
}
private async buildUserCollaborations(
userId: string,
workspaceId: string
): Promise<CreateCollaboration[]> {
const createCollaborations: CreateCollaboration[] = [];
createCollaborations.push(
buildDefaultCollaboration(userId, workspaceId, 'workspace', workspaceId)
);
const workspaceUserIds = await fetchWorkspaceUsers(workspaceId);
for (const workspaceUserId of workspaceUserIds) {
createCollaborations.push(
buildDefaultCollaboration(workspaceUserId, userId, 'user', workspaceId)
);
if (workspaceUserId === userId) {
continue;
}
createCollaborations.push(
buildDefaultCollaboration(userId, workspaceUserId, 'user', workspaceId)
);
}
return createCollaborations;
}
private buildNodeCollaborations(
nodeId: string,
workspaceId: string,
attributes: NodeAttributes,
ancestors: Node[]
): CreateCollaboration[] {
const collaborators = extractNodeCollaborators([
...ancestors.map((a) => a.attributes),
attributes,
]);
const collaboratorIds = Object.keys(collaborators);
const createCollaborations: CreateCollaboration[] = collaboratorIds.map(
(userId) =>
buildDefaultCollaboration(userId, nodeId, attributes.type, workspaceId)
);
return createCollaborations;
}
private checkCollaboratorChanges(
node: Node,
ancestors: Node[],
newAttributes: NodeAttributes
): CollaboratorChangeResult {
const beforeCollaborators = Object.keys(
extractNodeCollaborators(node.attributes)
);
const afterCollaborators = Object.keys(
extractNodeCollaborators(newAttributes)
);
if (beforeCollaborators.length === 0 && afterCollaborators.length === 0) {
return { removedCollaborators: [], addedCollaborators: [] };
}
const addedCollaborators = difference(
afterCollaborators,
beforeCollaborators
);
const removedCollaborators = difference(
beforeCollaborators,
afterCollaborators
);
if (addedCollaborators.length === 0 && removedCollaborators.length === 0) {
return { removedCollaborators: [], addedCollaborators: [] };
}
const inheritedCollaborators = Object.keys(
extractNodeCollaborators(ancestors.map((a) => a.attributes))
);
const added = difference(addedCollaborators, inheritedCollaborators);
const removed = difference(removedCollaborators, inheritedCollaborators);
return { removedCollaborators: removed, addedCollaborators: added };
}
}
export const nodeService = new NodeService();

View File

@@ -2,34 +2,51 @@ import { database } from '@/data/database';
import { Server } from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import { verifyToken } from '@/lib/tokens';
import { CHANNEL_NAMES } from '@/data/redis';
import { redis } from '@/data/redis';
import {
SynapseMessage,
SynapseNodeChangeMessage,
SynapseUserNodeChangeMessage,
} from '@/types/synapse';
import { getIdType, IdType } from '@colanode/core';
import { Message } from '@colanode/core';
CollaborationsBatchMessage,
Message,
NodeTransactionsBatchMessage,
} from '@colanode/core';
import { logService } from '@/services/log';
import { encodeState } from '@colanode/crdt';
import { mapCollaboration, mapNodeTransaction } from '@/lib/nodes';
import { eventBus } from '@/lib/event-bus';
import {
CollaborationCreatedEvent,
CollaborationUpdatedEvent,
NodeTransactionCreatedEvent,
} from '@/types/events';
interface SynapseUserCursor {
workspaceId: string;
userId: string;
cursor: string | null;
syncing: boolean;
}
interface SynapseConnection {
accountId: string;
deviceId: string;
workspaceUsers: {
workspaceId: string;
userId: string;
}[];
pendingSyncs: Set<string>;
socket: WebSocket;
pendingSyncTimeout: NodeJS.Timeout | null;
nodeTransactions: Map<string, SynapseUserCursor>;
collaborations: Map<string, SynapseUserCursor>;
}
class SynapseService {
private readonly logger = logService.createLogger('synapse-service');
private readonly connections: Map<string, SynapseConnection> = new Map();
constructor() {
eventBus.subscribe((event) => {
if (event.type === 'node_transaction_created') {
this.handleNodeTransactionCreatedEvent(event);
} else if (event.type === 'collaboration_created') {
this.handleCollaborationCreatedEvent(event);
} else if (event.type === 'collaboration_updated') {
this.handleCollaborationUpdatedEvent(event);
}
});
}
public async init(server: Server) {
this.logger.info('Initializing synapse service');
@@ -67,10 +84,6 @@ class SynapseService {
socket.on('close', () => {
const connection = this.connections.get(account.deviceId);
if (connection) {
if (connection.pendingSyncTimeout) {
clearTimeout(connection.pendingSyncTimeout);
}
this.connections.delete(account.deviceId);
}
});
@@ -78,10 +91,9 @@ class SynapseService {
const connection: SynapseConnection = {
accountId: account.id,
deviceId: account.deviceId,
workspaceUsers: [],
pendingSyncs: new Set(),
pendingSyncTimeout: null,
socket,
nodeTransactions: new Map(),
collaborations: new Map(),
};
socket.on('message', (message) => {
@@ -89,19 +101,10 @@ class SynapseService {
});
this.connections.set(account.deviceId, connection);
this.fetchWorkspaceUsers(connection).then(() => {
this.sendPendingChangesDebounced(connection);
});
});
const subscriber = redis.duplicate();
await subscriber.connect();
await subscriber.subscribe(CHANNEL_NAMES.SYNAPSE, (message) =>
this.handleSynapseMessage(message.toString())
);
}
private sendSocketMessage(connection: SynapseConnection, message: Message) {
private sendMessage(connection: SynapseConnection, message: Message) {
connection.socket.send(JSON.stringify(message));
}
@@ -111,472 +114,229 @@ class SynapseService {
) {
this.logger.trace(message, `Socket message from ${connection.deviceId}`);
if (message.type === 'local_node_sync') {
await database
.insertInto('device_nodes')
.values({
node_id: message.nodeId,
user_id: message.userId,
device_id: connection.deviceId,
node_version_id: message.versionId,
user_node_version_id: null,
user_node_synced_at: null,
workspace_id: message.workspaceId,
node_synced_at: new Date(),
})
.onConflict((cb) =>
cb.columns(['node_id', 'user_id', 'device_id']).doUpdateSet({
workspace_id: message.workspaceId,
node_version_id: message.versionId,
node_synced_at: new Date(),
})
)
.execute();
} else if (message.type === 'local_user_node_sync') {
await database
.insertInto('device_nodes')
.values({
node_id: message.nodeId,
user_id: message.userId,
device_id: connection.deviceId,
node_version_id: null,
user_node_version_id: message.versionId,
user_node_synced_at: new Date(),
workspace_id: message.workspaceId,
node_synced_at: new Date(),
})
.onConflict((cb) =>
cb.columns(['node_id', 'user_id', 'device_id']).doUpdateSet({
workspace_id: message.workspaceId,
user_node_version_id: message.versionId,
user_node_synced_at: new Date(),
})
)
.execute();
} else if (message.type === 'local_node_delete') {
await database
.deleteFrom('device_nodes')
.where('device_id', '=', connection.deviceId)
.where('node_id', '=', message.nodeId)
.execute();
if (message.type === 'fetch_node_transactions') {
const state = connection.nodeTransactions.get(message.userId);
if (!state) {
connection.nodeTransactions.set(message.userId, {
userId: message.userId,
workspaceId: message.workspaceId,
cursor: message.cursor,
syncing: false,
});
const userId = connection.workspaceUsers.find(
(wu) => wu.workspaceId === message.workspaceId
)?.userId;
this.sendPendingTransactions(connection, message.userId);
} else if (!state.syncing && !(state.cursor, message.cursor)) {
state.cursor = message.cursor;
this.sendPendingTransactions(connection, message.userId);
}
} else if (message.type === 'fetch_collaborations') {
const state = connection.collaborations.get(message.userId);
if (!state) {
connection.collaborations.set(message.userId, {
userId: message.userId,
workspaceId: message.workspaceId,
cursor: message.cursor,
syncing: false,
});
if (userId) {
const userDevices = await database
.selectFrom('devices')
.select('id')
.where(
'account_id',
'in',
database
.selectFrom('workspace_users')
.select('account_id')
.where('id', '=', userId)
)
.execute();
const deviceIds = userDevices.map((device) => device.id);
if (deviceIds.length > 0) {
const deviceNodes = await database
.selectFrom('device_nodes')
.select('node_id')
.where('device_id', 'in', deviceIds)
.where('node_id', '=', message.nodeId)
.execute();
if (deviceNodes.length === 0) {
await database
.deleteFrom('user_nodes')
.where('node_id', '=', message.nodeId)
.where('user_id', '=', userId)
.execute();
}
}
this.sendPendingCollaborations(connection, message.userId);
} else if (!state.syncing && !(state.cursor, message.cursor)) {
state.cursor = message.cursor;
this.sendPendingCollaborations(connection, message.userId);
}
}
this.sendPendingChangesDebounced(connection);
}
public async sendSynapseMessage(message: SynapseMessage) {
await redis.publish(CHANNEL_NAMES.SYNAPSE, JSON.stringify(message));
}
private async handleSynapseMessage(message: string) {
const data: SynapseMessage = JSON.parse(message);
this.logger.trace(data, 'Synapse message');
if (
data.type === 'node_create' ||
data.type === 'node_update' ||
data.type === 'node_delete'
) {
await this.handleNodeChangeMessage(data);
} else if (data.type === 'user_node_update') {
await this.handleUserNodeUpdateMessage(data);
}
}
private async handleNodeChangeMessage(data: SynapseNodeChangeMessage) {
this.logger.trace(data, 'Handling node change message');
const idType = getIdType(data.nodeId);
if (idType === IdType.User) {
await this.addNewWorkspaceUser(data.nodeId, data.workspaceId);
private async sendPendingTransactions(
connection: SynapseConnection,
userId: string
) {
const state = connection.nodeTransactions.get(userId);
if (!state || state.syncing) {
return;
}
await this.broadcastNodeChange(data);
state.syncing = true;
this.logger.trace(
state,
`Sending pending node transactions for ${connection.deviceId} with ${userId}`
);
let query = database
.selectFrom('node_transactions as nt')
.leftJoin('collaborations as c', (join) =>
join.on('c.user_id', '=', userId).onRef('c.node_id', '=', 'nt.node_id')
)
.selectAll('nt');
if (state.cursor) {
query = query.where('nt.number', '>', BigInt(state.cursor));
}
const unsyncedTransactions = await query
.orderBy('nt.number', 'asc')
.limit(20)
.execute();
if (unsyncedTransactions.length === 0) {
state.syncing = false;
return;
}
const transactions = unsyncedTransactions.map(mapNodeTransaction);
const message: NodeTransactionsBatchMessage = {
type: 'node_transactions_batch',
userId,
transactions,
};
connection.nodeTransactions.delete(userId);
this.sendMessage(connection, message);
}
private async broadcastNodeChange(data: SynapseNodeChangeMessage) {
const userDevices = this.getWorkspaceUserDevices(data.workspaceId);
private async sendPendingCollaborations(
connection: SynapseConnection,
userId: string
) {
const state = connection.collaborations.get(userId);
if (!state || state.syncing) {
return;
}
state.syncing = true;
this.logger.trace(
state,
`Sending pending collaborations for ${connection.deviceId} with ${userId}`
);
let query = database
.selectFrom('collaborations as c')
.selectAll()
.where('c.user_id', '=', userId);
if (state.cursor) {
query = query.where('c.number', '>', BigInt(state.cursor));
}
const unsyncedCollaborations = await query
.orderBy('c.number', 'asc')
.limit(20)
.execute();
if (unsyncedCollaborations.length === 0) {
state.syncing = false;
return;
}
const collaborations = unsyncedCollaborations.map(mapCollaboration);
const message: CollaborationsBatchMessage = {
type: 'collaborations_batch',
userId,
collaborations,
};
connection.collaborations.delete(userId);
this.sendMessage(connection, message);
}
private async handleNodeTransactionCreatedEvent(
event: NodeTransactionCreatedEvent
) {
const userDevices = this.getPendingNodeTransactionCursors(
event.workspaceId
);
const userIds = Array.from(userDevices.keys());
if (userIds.length === 0) {
return;
}
this.logger.trace(
userIds,
`Broadcasting node change for ${data.nodeId} to ${userIds.length} users`
);
const userNodes = await database
.selectFrom('user_nodes')
const collaborations = await database
.selectFrom('collaborations')
.selectAll()
.where((eb) =>
eb.and([eb('user_id', 'in', userIds), eb('node_id', '=', data.nodeId)])
eb.and([eb('user_id', 'in', userIds), eb('node_id', '=', event.nodeId)])
)
.execute();
this.logger.trace(
userNodes,
`User nodes for ${data.nodeId} with ${userNodes.length} users`
);
if (userNodes.length === 0) {
if (collaborations.length === 0) {
return;
}
if (data.type === 'node_delete') {
for (const userNode of userNodes) {
const deviceIds = userDevices.get(userNode.user_id) ?? [];
for (const deviceId of deviceIds) {
const socketConnection = this.connections.get(deviceId);
if (socketConnection === undefined) {
continue;
}
this.sendSocketMessage(socketConnection, {
type: 'server_node_delete',
id: data.nodeId,
workspaceId: data.workspaceId,
});
}
}
return;
}
const node = await database
.selectFrom('nodes')
.select([
'id',
'state',
'created_at',
'created_by',
'updated_at',
'updated_by',
'server_created_at',
'server_updated_at',
'version_id',
])
.where('id', '=', data.nodeId)
.executeTakeFirst();
if (!node) {
return;
}
for (const userNode of userNodes) {
const deviceIds = userDevices.get(userNode.user_id) ?? [];
if (deviceIds.length === 0) {
continue;
}
for (const collaboration of collaborations) {
const deviceIds = userDevices.get(collaboration.user_id) ?? [];
for (const deviceId of deviceIds) {
const socketConnection = this.connections.get(deviceId);
if (socketConnection === undefined) {
continue;
}
if (userNode.access_removed_at !== null) {
this.sendSocketMessage(socketConnection, {
type: 'server_node_delete',
id: data.nodeId,
workspaceId: data.workspaceId,
});
} else {
this.sendSocketMessage(socketConnection, {
type: 'server_node_sync',
node: {
id: node.id,
workspaceId: data.workspaceId,
state: encodeState(node.state),
createdAt: node.created_at.toISOString(),
createdBy: node.created_by,
updatedAt: node.updated_at?.toISOString() ?? null,
updatedBy: node.updated_by ?? null,
serverCreatedAt: node.server_created_at.toISOString(),
serverUpdatedAt: node.server_updated_at?.toISOString() ?? null,
versionId: node.version_id,
},
});
}
this.sendPendingTransactions(socketConnection, collaboration.user_id);
}
}
}
private async handleUserNodeUpdateMessage(
data: SynapseUserNodeChangeMessage
) {
const userDevices = this.getWorkspaceUserDevices(data.workspaceId);
if (!userDevices.has(data.userId)) {
private handleCollaborationCreatedEvent(event: CollaborationCreatedEvent) {
const userDevices = this.getPendingCollaborationCursors(event.userId);
if (userDevices.length === 0) {
return;
}
const userNode = await database
.selectFrom('user_nodes')
.selectAll()
.where('user_id', '=', data.userId)
.where('node_id', '=', data.nodeId)
.executeTakeFirst();
if (!userNode) {
return;
}
const deviceIds = userDevices.get(data.userId) ?? [];
for (const deviceId of deviceIds) {
for (const deviceId of userDevices) {
const socketConnection = this.connections.get(deviceId);
if (socketConnection === undefined) {
continue;
}
this.sendSocketMessage(socketConnection, {
type: 'server_user_node_sync',
userNode: {
userId: data.userId,
nodeId: data.nodeId,
lastSeenVersionId: userNode.last_seen_version_id,
workspaceId: data.workspaceId,
versionId: userNode.version_id,
lastSeenAt: userNode.last_seen_at?.toISOString() ?? null,
createdAt: userNode.created_at.toISOString(),
updatedAt: userNode.updated_at?.toISOString() ?? null,
mentionsCount: userNode.mentions_count,
},
});
this.sendPendingCollaborations(socketConnection, event.userId);
}
}
private sendPendingChangesDebounced(connection: SynapseConnection) {
if (connection.pendingSyncTimeout) {
clearTimeout(connection.pendingSyncTimeout);
}
connection.pendingSyncTimeout = setTimeout(async () => {
await this.sendPendingChanges(connection);
}, 500);
}
private async sendPendingChanges(connection: SynapseConnection) {
const userIds = connection.workspaceUsers.map(
(workspaceUser) => workspaceUser.userId
);
if (userIds.length === 0) {
private handleCollaborationUpdatedEvent(event: CollaborationUpdatedEvent) {
const userDevices = this.getPendingCollaborationCursors(event.userId);
if (userDevices.length === 0) {
return;
}
this.logger.trace(
userIds,
`Sending pending changes for ${connection.deviceId} with ${userIds.length} users`
);
const unsyncedNodes = await database
.selectFrom('user_nodes as nus')
.leftJoin('nodes as n', 'n.id', 'nus.node_id')
.leftJoin('device_nodes as nds', (join) =>
join
.onRef('nds.node_id', '=', 'nus.node_id')
.on('nds.device_id', '=', connection.deviceId)
)
.select([
'n.id',
'n.state',
'n.created_at',
'n.created_by',
'n.updated_at',
'n.updated_by',
'n.server_created_at',
'n.server_updated_at',
'nus.access_removed_at',
'n.version_id as node_version_id',
'nus.node_id',
'nus.user_id',
'nus.workspace_id',
'nus.last_seen_version_id',
'nus.last_seen_at',
'nus.mentions_count',
'nus.created_at',
'nus.updated_at',
'nus.version_id as user_node_version_id',
'nds.node_version_id as device_node_version_id',
'nds.user_node_version_id as device_user_node_version_id',
])
.where((eb) =>
eb.and([
eb('nus.user_id', 'in', userIds),
eb.or([
eb('n.id', 'is', null),
eb('nus.access_removed_at', 'is not', null),
eb('nds.node_version_id', 'is', null),
eb('nds.node_version_id', '!=', eb.ref('n.version_id')),
eb('nds.user_node_version_id', 'is', null),
eb('nds.user_node_version_id', '!=', eb.ref('nus.version_id')),
]),
])
)
.orderBy('n.id', 'asc')
.limit(100)
.execute();
if (unsyncedNodes.length === 0) {
return;
}
for (const row of unsyncedNodes) {
connection.pendingSyncs.add(row.node_id);
if (row.id === null) {
this.sendSocketMessage(connection, {
type: 'server_node_delete',
id: row.node_id,
workspaceId: row.workspace_id,
});
for (const deviceId of userDevices) {
const socketConnection = this.connections.get(deviceId);
if (socketConnection === undefined) {
continue;
}
if (row.node_version_id !== row.device_node_version_id) {
this.sendSocketMessage(connection, {
type: 'server_node_sync',
node: {
id: row.id,
workspaceId: row.workspace_id,
state: encodeState(row.state!),
createdAt: row.created_at!.toISOString(),
createdBy: row.created_by!,
updatedAt: row.updated_at?.toISOString() ?? null,
updatedBy: row.updated_by ?? null,
serverCreatedAt: row.server_created_at!.toISOString(),
serverUpdatedAt: row.server_updated_at?.toISOString() ?? null,
versionId: row.node_version_id!,
},
});
}
if (row.user_node_version_id !== row.device_user_node_version_id) {
this.sendSocketMessage(connection, {
type: 'server_user_node_sync',
userNode: {
nodeId: row.node_id,
userId: row.user_id,
workspaceId: row.workspace_id,
versionId: row.user_node_version_id!,
lastSeenAt: row.last_seen_at?.toISOString() ?? null,
lastSeenVersionId: row.last_seen_version_id ?? null,
mentionsCount: row.mentions_count,
createdAt: row.created_at!.toISOString(),
updatedAt: row.updated_at?.toISOString() ?? null,
},
});
}
this.sendPendingCollaborations(socketConnection, event.userId);
}
}
private async addNewWorkspaceUser(userId: string, workspaceId: string) {
this.logger.trace(`Adding new workspace user ${userId} to ${workspaceId}`);
const workspaceUser = await database
.selectFrom('workspace_users')
.selectAll()
.where('id', '=', userId)
.executeTakeFirst();
if (!workspaceUser) {
return;
}
const devices = await database
.selectFrom('devices')
.selectAll()
.where('account_id', '=', workspaceUser.account_id)
.execute();
for (const device of devices) {
const connection = this.connections.get(device.id);
if (!connection) {
continue;
}
if (connection.workspaceUsers.find((wu) => wu.userId === userId)) {
continue;
}
connection.workspaceUsers.push({
workspaceId,
userId,
});
this.sendPendingChangesDebounced(connection);
}
}
private async fetchWorkspaceUsers(
connection: SynapseConnection
): Promise<void> {
const workspaceUsers = await database
.selectFrom('workspace_users')
.selectAll()
.where('account_id', '=', connection.accountId)
.execute();
for (const workspaceUser of workspaceUsers) {
if (
!connection.workspaceUsers.find((wu) => wu.userId === workspaceUser.id)
) {
connection.workspaceUsers.push({
workspaceId: workspaceUser.workspace_id,
userId: workspaceUser.id,
});
}
}
}
private getWorkspaceUserDevices(workspaceId: string): Map<string, string[]> {
private getPendingNodeTransactionCursors(
workspaceId: string
): Map<string, string[]> {
const userDevices = new Map<string, string[]>();
for (const connection of this.connections.values()) {
const workspaceUsers = connection.workspaceUsers;
for (const workspaceUser of workspaceUsers) {
if (workspaceUser.workspaceId !== workspaceId) {
const connectionUsers = connection.nodeTransactions.values();
for (const user of connectionUsers) {
if (user.workspaceId !== workspaceId || user.syncing) {
continue;
}
const userIds = userDevices.get(workspaceUser.userId) ?? [];
const userIds = userDevices.get(user.userId) ?? [];
userIds.push(connection.deviceId);
userDevices.set(workspaceUser.userId, userIds);
userDevices.set(user.userId, userIds);
}
}
return userDevices;
}
private getPendingCollaborationCursors(userId: string): string[] {
const userDevices: string[] = [];
for (const connection of this.connections.values()) {
const connectionUsers = connection.collaborations.values();
for (const user of connectionUsers) {
if (user.userId !== userId || user.syncing) {
continue;
}
userDevices.push(connection.deviceId);
}
}

View File

@@ -0,0 +1,164 @@
import { database } from '@/data/database';
import {
generateId,
IdType,
WorkspaceCreateInput,
WorkspaceOutput,
WorkspaceStatus,
WorkspaceUserStatus,
} from '@colanode/core';
import { nodeService } from './node-service';
import { mapNode } from '@/lib/nodes';
import { SelectAccount } from '@/data/schema';
class WorkspaceService {
public async createWorkspace(
account: SelectAccount,
input: WorkspaceCreateInput
): Promise<WorkspaceOutput> {
const date = new Date();
const workspaceId = generateId(IdType.Workspace);
const userId = generateId(IdType.User);
const spaceId = generateId(IdType.Space);
const workspace = await database
.insertInto('workspaces')
.values({
id: workspaceId,
name: input.name,
description: input.description,
avatar: input.avatar,
created_at: date,
created_by: account.id,
status: WorkspaceStatus.Active,
version_id: generateId(IdType.Version),
})
.returningAll()
.executeTakeFirst();
if (!workspace) {
throw new Error('Failed to create workspace.');
}
const workspaceUser = await database
.insertInto('workspace_users')
.values({
id: userId,
account_id: account.id,
workspace_id: workspaceId,
role: 'owner',
created_at: date,
created_by: account.id,
status: WorkspaceUserStatus.Active,
version_id: generateId(IdType.Version),
})
.returningAll()
.executeTakeFirst();
if (!workspaceUser) {
throw new Error('Failed to create workspace user.');
}
const createWorkspaceNodeOutput = await nodeService.createNode({
nodeId: workspaceId,
attributes: {
type: 'workspace',
name: input.name,
description: input.description,
avatar: input.avatar,
parentId: workspaceId,
},
userId: userId,
workspaceId: workspaceId,
ancestors: [],
});
if (!createWorkspaceNodeOutput) {
throw new Error('Failed to create workspace node.');
}
await nodeService.createNode({
nodeId: userId,
attributes: {
type: 'user',
name: account.name,
email: account.email,
role: 'owner',
accountId: account.id,
parentId: workspaceId,
},
userId: userId,
workspaceId: workspaceId,
ancestors: [],
});
const createSpaceNodeOutput = await nodeService.createNode({
nodeId: spaceId,
attributes: {
type: 'space',
name: 'Home',
description: 'This is your home space.',
parentId: workspaceId,
collaborators: {
[userId]: 'admin',
},
},
userId: userId,
workspaceId: workspaceId,
ancestors: [],
});
if (createSpaceNodeOutput) {
const spaceNode = mapNode(createSpaceNodeOutput.node);
await nodeService.createNode({
nodeId: generateId(IdType.Page),
attributes: {
type: 'page',
name: 'Notes',
parentId: spaceId,
content: {},
},
userId: userId,
workspaceId: workspaceId,
ancestors: [spaceNode],
});
await nodeService.createNode({
nodeId: generateId(IdType.Channel),
attributes: {
type: 'channel',
name: 'Discussions',
parentId: spaceId,
},
userId: userId,
workspaceId: workspaceId,
ancestors: [spaceNode],
});
}
return {
id: workspace.id,
name: workspace.name,
description: workspace.description,
avatar: workspace.avatar,
versionId: workspace.version_id,
user: {
id: workspaceUser.id,
accountId: workspaceUser.account_id,
role: workspaceUser.role,
},
};
}
public async createDefaultWorkspace(account: SelectAccount) {
const input: WorkspaceCreateInput = {
name: `${account.name}'s Workspace`,
description: 'This is your personal workspace.',
avatar: '',
};
return this.createWorkspace(account, input);
}
}
export const workspaceService = new WorkspaceService();

View File

@@ -1,34 +1,25 @@
import { NodeAttributes } from '@colanode/core';
export type NodeEvent = NodeCreatedEvent | NodeUpdatedEvent | NodeDeletedEvent;
export type NodeCreatedEvent = {
type: 'node_created';
id: string;
export type NodeTransactionCreatedEvent = {
type: 'node_transaction_created';
transactionId: string;
nodeId: string;
workspaceId: string;
attributes: NodeAttributes;
createdBy: string;
createdAt: string;
serverCreatedAt: string;
versionId: string;
};
export type NodeUpdatedEvent = {
type: 'node_updated';
id: string;
export type CollaborationCreatedEvent = {
type: 'collaboration_created';
userId: string;
nodeId: string;
workspaceId: string;
beforeAttributes: NodeAttributes;
afterAttributes: NodeAttributes;
updatedBy: string;
updatedAt: string;
serverUpdatedAt: string;
versionId: string;
};
export type NodeDeletedEvent = {
type: 'node_deleted';
id: string;
export type CollaborationUpdatedEvent = {
type: 'collaboration_updated';
userId: string;
nodeId: string;
workspaceId: string;
attributes: NodeAttributes;
deletedAt: string;
};
export type Event =
| NodeTransactionCreatedEvent
| CollaborationCreatedEvent
| CollaborationUpdatedEvent;

View File

@@ -1,5 +1,80 @@
import {
SelectCollaboration,
SelectNode,
SelectNodeTransaction,
} from '@/data/schema';
import { Node, NodeAttributes } from '@colanode/core';
export type NodeCollaborator = {
nodeId: string;
collaboratorId: string;
role: string;
};
export type CreateNodeInput = {
nodeId: string;
attributes: NodeAttributes;
userId: string;
workspaceId: string;
ancestors: Node[];
};
export type CreateNodeOutput = {
node: SelectNode;
transaction: SelectNodeTransaction;
createdCollaborations: SelectCollaboration[];
};
export type UpdateNodeInput = {
nodeId: string;
userId: string;
workspaceId: string;
updater: (attributes: NodeAttributes) => NodeAttributes | null;
};
export type UpdateNodeOutput = {
node: SelectNode;
transaction: SelectNodeTransaction;
createdCollaborations: SelectCollaboration[];
updatedCollaborations: SelectCollaboration[];
};
export type ApplyNodeCreateTransactionInput = {
id: string;
nodeId: string;
data: string | Uint8Array;
createdAt: Date;
};
export type ApplyNodeCreateTransactionOutput = {
node: SelectNode;
transaction: SelectNodeTransaction;
collaborations: SelectCollaboration[];
};
export type ApplyNodeUpdateTransactionInput = {
id: string;
nodeId: string;
userId: string;
data: string | Uint8Array;
createdAt: Date;
};
export type ApplyNodeUpdateTransactionOutput = {
node: SelectNode;
transaction: SelectNodeTransaction;
createdCollaborations: SelectCollaboration[];
updatedCollaborations: SelectCollaboration[];
};
export type ApplyNodeDeleteTransactionInput = {
id: string;
nodeId: string;
createdAt: Date;
};
export type ApplyNodeDeleteTransactionOutput = {
node: SelectNode;
transaction: SelectNodeTransaction;
updatedCollaborations: SelectCollaboration[];
};

View File

@@ -1,16 +0,0 @@
export type SynapseNodeChangeMessage = {
workspaceId: string;
nodeId: string;
type: 'node_create' | 'node_update' | 'node_delete';
};
export type SynapseUserNodeChangeMessage = {
workspaceId: string;
nodeId: string;
userId: string;
type: 'user_node_update';
};
export type SynapseMessage =
| SynapseNodeChangeMessage
| SynapseUserNodeChangeMessage;

View File

@@ -1,22 +1,8 @@
import { EmailMessage } from '@/types/email';
export type Task =
| CleanDeviceDataTask
| SendEmailTask
| CleanUserDeviceNodesTask;
export type CleanDeviceDataTask = {
type: 'clean_device_data';
deviceId: string;
};
export type Task = SendEmailTask;
export type SendEmailTask = {
type: 'send_email';
message: EmailMessage;
};
export type CleanUserDeviceNodesTask = {
type: 'clean_user_device_nodes';
userId: string;
workspaceId: string;
};