mirror of
https://github.com/colanode/colanode.git
synced 2025-12-29 00:25:03 +01:00
New version of syncing
This commit is contained in:
Binary file not shown.
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
80
apps/desktop/src/main/services/collaboration-service.ts
Normal file
80
apps/desktop/src/main/services/collaboration-service.ts
Normal 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();
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const RecordBody = ({
|
||||
<Document
|
||||
nodeId={record.id}
|
||||
content={record.attributes.content}
|
||||
versionId={record.versionId}
|
||||
transactionId={record.transactionId}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -156,7 +156,7 @@ export const RecordProvider = ({
|
||||
|
||||
return null;
|
||||
},
|
||||
versionId: record.versionId,
|
||||
transactionId: record.transactionId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const RecordSelectValue = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedValue(record.getSelectValue(field));
|
||||
}, [record.versionId]);
|
||||
}, [record.transactionId]);
|
||||
|
||||
const selectedOption = field.options?.[selectedValue ?? ''];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
28
apps/server/src/lib/collaborations.ts
Normal file
28
apps/server/src/lib/collaborations.ts
Normal 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(),
|
||||
};
|
||||
};
|
||||
44
apps/server/src/lib/event-bus.ts
Normal file
44
apps/server/src/lib/event-bus.ts
Normal 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();
|
||||
@@ -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')
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
883
apps/server/src/services/node-service.ts
Normal file
883
apps/server/src/services/node-service.ts
Normal 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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
164
apps/server/src/services/workspace-service.ts
Normal file
164
apps/server/src/services/workspace-service.ts
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user