diff --git a/desktop/src/components/channels/channel-container-node.tsx b/desktop/src/components/channels/channel-container-node.tsx index 7ad27736..6eebe813 100644 --- a/desktop/src/components/channels/channel-container-node.tsx +++ b/desktop/src/components/channels/channel-container-node.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; import { Conversation } from '@/components/messages/conversation'; interface ChannelContainerNodeProps { - node: Node; + node: LocalNode; } export const ChannelContainerNode = ({ node }: ChannelContainerNodeProps) => { diff --git a/desktop/src/components/channels/channel-sidebar-node.tsx b/desktop/src/components/channels/channel-sidebar-node.tsx index a0ad70f4..7b87f0bb 100644 --- a/desktop/src/components/channels/channel-sidebar-node.tsx +++ b/desktop/src/components/channels/channel-sidebar-node.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; import { cn } from '@/lib/utils'; import { Avatar } from '@/components/ui/avatar'; import { Icon } from '@/components/ui/icon'; import { useWorkspace } from '@/contexts/workspace'; interface ChannelSidebarNodeProps { - node: Node; + node: LocalNode; } export const ChannelSidebarNode = ({ node }: ChannelSidebarNodeProps) => { diff --git a/desktop/src/components/documents/document-editor.tsx b/desktop/src/components/documents/document-editor.tsx index ddf4a557..97e3aa81 100644 --- a/desktop/src/components/documents/document-editor.tsx +++ b/desktop/src/components/documents/document-editor.tsx @@ -47,7 +47,7 @@ import { } from '@/editor/extensions'; import { EditorBubbleMenu } from '@/editor/menu/bubble-menu'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; import { editorNodeMapEquals, mapEditorNodesToJSONContent, @@ -57,17 +57,12 @@ import { import { EditorNode } from '@/types/editor'; import { Editor } from '@tiptap/core'; import { debounce, isEqual } from 'lodash'; -import { - LocalCreateNodesMutation, - LocalDeleteNodesMutation, - LocalUpdateNodeMutation, -} from '@/types/mutations'; import { useWorkspace } from '@/contexts/workspace'; import { NeuronId } from '@/lib/id'; interface DocumentEditorProps { - node: Node; - nodes: Node[]; + node: LocalNode; + nodes: LocalNode[]; } export const DocumentEditor = ({ node, nodes }: DocumentEditorProps) => { @@ -161,70 +156,59 @@ export const DocumentEditor = ({ node, nodes }: DocumentEditorProps) => { return; } - const createNodesMutation: LocalCreateNodesMutation = { - type: 'create_nodes', - data: { - nodes: [], - }, - }; - const updateNodeMutations: LocalUpdateNodeMutation[] = []; - const deleteNodesMutation: LocalDeleteNodesMutation = { - type: 'delete_nodes', - data: { - ids: [], - }, - }; - for (const newEditorNode of newEditorNodes.values()) { const existingEditorNode = nodesSnapshot.current.get(newEditorNode.id); if (!existingEditorNode) { - createNodesMutation.data.nodes.push({ - id: newEditorNode.id, - type: newEditorNode.type, - workspaceId: workspace.id, - parentId: newEditorNode.parentId, - index: newEditorNode.index, - attrs: newEditorNode.attrs, - content: newEditorNode.content, - createdAt: new Date().toISOString(), - createdBy: workspace.userId, - versionId: NeuronId.generate(NeuronId.Type.Version), - }); - } else if (!isEqual(existingEditorNode, newEditorNode)) { - updateNodeMutations.push({ - type: 'update_node', - data: { + const query = workspace.schema + .insertInto('nodes') + .values({ id: newEditorNode.id, type: newEditorNode.type, - parentId: newEditorNode.parentId, + parent_id: newEditorNode.parentId, index: newEditorNode.index, - attrs: newEditorNode.attrs, - content: newEditorNode.content, - updatedAt: new Date().toISOString(), - updatedBy: workspace.userId, - versionId: NeuronId.generate(NeuronId.Type.Version), - }, - }); - } - } + attrs: newEditorNode.attrs + ? JSON.stringify(newEditorNode.attrs) + : null, + content: newEditorNode.content + ? JSON.stringify(newEditorNode.content) + : null, + created_at: new Date().toISOString(), + created_by: workspace.userId, + version_id: NeuronId.generate(NeuronId.Type.Version), + }) + .compile(); - for (const existingEditorNode of nodesSnapshot.current.values()) { - if (!newEditorNodes.has(existingEditorNode.id)) { - deleteNodesMutation.data.ids.push(existingEditorNode.id); - } - } + await workspace.mutate(query); + } else if (!isEqual(existingEditorNode, newEditorNode)) { + const updateNodeMutation = workspace.schema + .updateTable('nodes') + .set({ + attrs: newEditorNode.attrs + ? JSON.stringify(newEditorNode.attrs) + : null, + content: newEditorNode.content + ? JSON.stringify(newEditorNode.content) + : null, + }) + .compile(); - if (createNodesMutation.data.nodes.length > 0) { - await workspace.mutate(createNodesMutation); - } - - if (updateNodeMutations.length > 0) { - for (const updateNodeMutation of updateNodeMutations) { await workspace.mutate(updateNodeMutation); } } - if (deleteNodesMutation.data.ids.length > 0) { + const toDeleteIds: string[] = []; + for (const existingEditorNode of nodesSnapshot.current.values()) { + if (!newEditorNodes.has(existingEditorNode.id)) { + toDeleteIds.push(existingEditorNode.id); + } + } + + if (toDeleteIds.length > 0) { + const deleteNodesMutation = workspace.schema + .deleteFrom('nodes') + .where('id', 'in', toDeleteIds) + .compile(); + await workspace.mutate(deleteNodesMutation); } @@ -234,7 +218,7 @@ export const DocumentEditor = ({ node, nodes }: DocumentEditorProps) => { ); const checkForRemoteChanges = React.useCallback( - debounce((remoteNodes: Node[], editor: Editor) => { + debounce((remoteNodes: LocalNode[], editor: Editor) => { const remoteEditorNodes = mapNodesToEditorNodes(remoteNodes); if (editorNodeMapEquals(remoteEditorNodes, nodesSnapshot.current)) { return; diff --git a/desktop/src/components/documents/document.tsx b/desktop/src/components/documents/document.tsx index bddbab77..f156cb74 100644 --- a/desktop/src/components/documents/document.tsx +++ b/desktop/src/components/documents/document.tsx @@ -1,14 +1,14 @@ import React from 'react'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; import { DocumentEditor } from './document-editor'; import { useQuery } from '@tanstack/react-query'; import { sql } from 'kysely'; -import { NodesTableSchema } from '@/data/schemas/workspace'; +import { SelectNode } from '@/data/schemas/workspace'; import { useWorkspace } from '@/contexts/workspace'; import { mapNode } from '@/lib/nodes'; interface DocumentProps { - node: Node; + node: LocalNode; } export const Document = ({ node }: DocumentProps) => { @@ -16,7 +16,7 @@ export const Document = ({ node }: DocumentProps) => { const { data, isPending } = useQuery({ queryKey: [`document:nodes:${node.id}`], queryFn: async ({ queryKey }) => { - const query = sql` + const query = sql` WITH RECURSIVE document_hierarchy AS ( SELECT * FROM nodes diff --git a/desktop/src/components/messages/message-delete-button.tsx b/desktop/src/components/messages/message-delete-button.tsx index 4c12a117..cafe1163 100644 --- a/desktop/src/components/messages/message-delete-button.tsx +++ b/desktop/src/components/messages/message-delete-button.tsx @@ -21,12 +21,12 @@ export const MessageDeleteButton = ({ id }: MessageDeleteButtonProps) => { const [showDeleteModal, setShowDeleteModal] = React.useState(false); const { mutate, isPending } = useMutation({ mutationFn: async (messageId: string) => { - await workspace.mutate({ - type: 'delete_node', - data: { - id: messageId, - }, - }); + const mutation = workspace.schema + .deleteFrom('nodes') + .where('id', '=', messageId) + .compile(); + + await workspace.mutate(mutation); }, }); const workspace = useWorkspace(); diff --git a/desktop/src/components/messages/message-reactions.tsx b/desktop/src/components/messages/message-reactions.tsx index 910d0816..0aa6f0ae 100644 --- a/desktop/src/components/messages/message-reactions.tsx +++ b/desktop/src/components/messages/message-reactions.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; interface MessageReactionsProps { - message: Node; + message: LocalNode; onReactionClick: (reaction: string) => void; } diff --git a/desktop/src/components/pages/page-container-node.tsx b/desktop/src/components/pages/page-container-node.tsx index 0aec5a98..ca074744 100644 --- a/desktop/src/components/pages/page-container-node.tsx +++ b/desktop/src/components/pages/page-container-node.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; import { Document } from '@/components/documents/document'; interface PageContainerNodeProps { - node: Node; + node: LocalNode; } export const PageContainerNode = ({ node }: PageContainerNodeProps) => { diff --git a/desktop/src/components/pages/page-sidebar-node.tsx b/desktop/src/components/pages/page-sidebar-node.tsx index 76c1271a..e906bc16 100644 --- a/desktop/src/components/pages/page-sidebar-node.tsx +++ b/desktop/src/components/pages/page-sidebar-node.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; import { cn } from '@/lib/utils'; import { Avatar } from '@/components/ui/avatar'; import { Icon } from '@/components/ui/icon'; import { useWorkspace } from '@/contexts/workspace'; interface PageSidebarNodeProps { - node: Node; + node: LocalNode; } export const PageSidebarNode = ({ node }: PageSidebarNodeProps) => { diff --git a/desktop/src/components/spaces/space-create-dialog.tsx b/desktop/src/components/spaces/space-create-dialog.tsx index b9d837ab..75f90ac8 100644 --- a/desktop/src/components/spaces/space-create-dialog.tsx +++ b/desktop/src/components/spaces/space-create-dialog.tsx @@ -56,56 +56,54 @@ export const SpaceCreateDialog = ({ const spaceId = NeuronId.generate(NeuronId.Type.Space); const firstIndex = generateNodeIndex(null, null); const secondIndex = generateNodeIndex(firstIndex, null); - await workspace.mutate({ - type: 'create_nodes', - data: { - nodes: [ - { - id: spaceId, - type: NodeTypes.Space, - parentId: null, - workspaceId: workspace.id, - index: generateNodeIndex(null, null), - attrs: { - name: values.name, - description: values.description, - }, - content: null, - createdAt: new Date().toISOString(), - createdBy: workspace.userId, - versionId: NeuronId.generate(NeuronId.Type.Version), - }, - { - id: NeuronId.generate(NeuronId.Type.Page), - type: NodeTypes.Page, - parentId: spaceId, - workspaceId: workspace.id, - index: firstIndex, - attrs: { - name: 'Home', - }, - content: null, - createdAt: new Date().toISOString(), - createdBy: workspace.userId, - versionId: NeuronId.generate(NeuronId.Type.Version), - }, - { - id: NeuronId.generate(NeuronId.Type.Channel), - type: NodeTypes.Channel, - parentId: spaceId, - workspaceId: workspace.id, - index: secondIndex, - attrs: { - name: 'Discussions', - }, - content: null, - createdAt: new Date().toISOString(), - createdBy: workspace.userId, - versionId: NeuronId.generate(NeuronId.Type.Version), - }, - ], - }, - }); + const query = workspace + .schema + .insertInto('nodes') + .values([ + { + id: spaceId, + type: NodeTypes.Space, + parent_id: null, + index: generateNodeIndex(null, null), + attrs: JSON.stringify({ + name: values.name, + description: values.description, + }), + content: null, + created_at: new Date().toISOString(), + created_by: workspace.userId, + version_id: NeuronId.generate(NeuronId.Type.Version), + }, + { + id: NeuronId.generate(NeuronId.Type.Page), + type: NodeTypes.Page, + parent_id: spaceId, + index: firstIndex, + attrs: JSON.stringify({ + name: 'Home', + }), + content: null, + created_at: new Date().toISOString(), + created_by: workspace.userId, + version_id: NeuronId.generate(NeuronId.Type.Version), + }, + { + id: NeuronId.generate(NeuronId.Type.Channel), + type: NodeTypes.Channel, + parent_id: spaceId, + index: secondIndex, + attrs: JSON.stringify({ + name: 'Discussions', + }), + content: null, + created_at: new Date().toISOString(), + created_by: workspace.userId, + version_id: NeuronId.generate(NeuronId.Type.Version), + }, + ]) + .compile(); + + await workspace.mutate(query); }, }); diff --git a/desktop/src/components/spaces/space-sidebar-node.tsx b/desktop/src/components/spaces/space-sidebar-node.tsx index 93f7684d..29e6dc21 100644 --- a/desktop/src/components/spaces/space-sidebar-node.tsx +++ b/desktop/src/components/spaces/space-sidebar-node.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; import { cn } from '@/lib/utils'; import { Avatar } from '@/components/ui/avatar'; import { Icon } from '@/components/ui/icon'; import { SidebarNodeChildren } from '@/components/workspaces/sidebar-node-children'; interface SpaceSidebarNodeProps { - node: Node; + node: LocalNode; } export const SpaceSidebarNode = ({ node }: SpaceSidebarNodeProps) => { diff --git a/desktop/src/components/workspaces/container-header.tsx b/desktop/src/components/workspaces/container-header.tsx index 2495e413..83d76127 100644 --- a/desktop/src/components/workspaces/container-header.tsx +++ b/desktop/src/components/workspaces/container-header.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; import { Avatar } from '@/components/ui/avatar'; import { Breadcrumb, @@ -27,11 +27,11 @@ import { debounce } from 'lodash'; import { sql } from 'kysely'; import { mapNode } from '@/lib/nodes'; import { useQuery } from '@tanstack/react-query'; -import { NodesTableSchema } from '@/data/schemas/workspace'; +import { SelectNode } from '@/data/schemas/workspace'; import { NeuronId } from '@/lib/id'; interface BreadcrumbNodeProps { - node: Node; + node: LocalNode; className?: string; } @@ -50,7 +50,7 @@ const BreadcrumbNode = ({ node, className }: BreadcrumbNodeProps) => { }; interface BreadcrumbNodeEditorProps { - node: Node; + node: LocalNode; } export const BreadcrumbNodeEditor = ({ node }: BreadcrumbNodeEditorProps) => { @@ -60,23 +60,21 @@ export const BreadcrumbNodeEditor = ({ node }: BreadcrumbNodeEditorProps) => { const handleNameChange = React.useMemo( () => debounce(async (newName: string) => { - await workspace.mutate({ - type: 'update_node', - data: { - id: node.id, - type: node.type, - index: node.index, - content: node.content, - attrs: { - ...node.attrs, - name: newName, - }, - parentId: node.parentId, - updatedAt: new Date().toISOString(), - updatedBy: workspace.userId, - versionId: NeuronId.generate(NeuronId.Type.Version), - }, - }); + const newAttrs = { + ...node.attrs, + name: newName, + }; + const query = workspace.schema + .updateTable('nodes') + .set({ + attrs: newAttrs ? JSON.stringify(newAttrs) : null, + updated_at: new Date().toISOString(), + updated_by: workspace.userId, + version_id: NeuronId.generate(NeuronId.Type.Version), + }) + .compile(); + + await workspace.mutate(query); }, 500), [node.id], ); @@ -97,7 +95,7 @@ export const BreadcrumbNodeEditor = ({ node }: BreadcrumbNodeEditorProps) => { }; interface ContainerHeaderProps { - node: Node; + node: LocalNode; } export const ContainerHeader = ({ node }: ContainerHeaderProps) => { @@ -105,7 +103,7 @@ export const ContainerHeader = ({ node }: ContainerHeaderProps) => { const { data, isPending } = useQuery({ queryKey: [`breadcrumb:${node.id}`], queryFn: async ({ queryKey }) => { - const query = sql` + const query = sql` WITH RECURSIVE breadcrumb AS ( SELECT * FROM nodes @@ -135,7 +133,7 @@ export const ContainerHeader = ({ node }: ContainerHeaderProps) => { const ellipsisNodes = showEllipsis ? breadcrumbNodes.slice(1, -1) : []; const lastNodes = showEllipsis ? breadcrumbNodes.slice(-1) : []; - const isClickable = (node: Node) => node.type !== NodeTypes.Space; + const isClickable = (node: LocalNode) => node.type !== NodeTypes.Space; return ( diff --git a/desktop/src/components/workspaces/sidebar-node-children.tsx b/desktop/src/components/workspaces/sidebar-node-children.tsx index c1d4dc16..cda192d8 100644 --- a/desktop/src/components/workspaces/sidebar-node-children.tsx +++ b/desktop/src/components/workspaces/sidebar-node-children.tsx @@ -1,15 +1,15 @@ import React from 'react'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; import { SidebarNode } from '@/components/workspaces/sidebar-node'; import { useSidebar } from '@/contexts/sidebar'; interface SidebarNodeChildrenProps { - node: Node; + node: LocalNode; } export const SidebarNodeChildren = ({ node }: SidebarNodeChildrenProps) => { const sidebar = useSidebar(); - const children: Node[] = sidebar.nodes + const children: LocalNode[] = sidebar.nodes .filter((n) => n.parentId === node.id) .sort((a, b) => { if (a.index < b.index) { diff --git a/desktop/src/components/workspaces/sidebar-node.tsx b/desktop/src/components/workspaces/sidebar-node.tsx index 02b52a50..d77e63e6 100644 --- a/desktop/src/components/workspaces/sidebar-node.tsx +++ b/desktop/src/components/workspaces/sidebar-node.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; import { match } from 'ts-pattern'; import { SpaceSidebarNode } from '@/components/spaces/space-sidebar-node'; import { NodeTypes } from '@/lib/constants'; @@ -7,7 +7,7 @@ import { ChannelSidebarNode } from '@/components/channels/channel-sidebar-node'; import { PageSidebarNode } from '@/components/pages/page-sidebar-node'; interface SidebarNodeProps { - node: Node; + node: LocalNode; } export const SidebarNode = ({ node }: SidebarNodeProps): React.ReactNode => { diff --git a/desktop/src/components/workspaces/sidebar.tsx b/desktop/src/components/workspaces/sidebar.tsx index 0ebc5443..47b8c7a4 100644 --- a/desktop/src/components/workspaces/sidebar.tsx +++ b/desktop/src/components/workspaces/sidebar.tsx @@ -3,7 +3,7 @@ import { SidebarHeader } from '@/components/workspaces/sidebar-header'; import { cn } from '@/lib/utils'; import { Icon } from '@/components/ui/icon'; import { SidebarSpaces } from '@/components/workspaces/sidebar-spaces'; -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; import { SidebarContext } from '@/contexts/sidebar'; import { Spinner } from '@/components/ui/spinner'; import { mapNode } from '@/lib/nodes'; @@ -59,7 +59,7 @@ export const Sidebar = () => { } const currentLayout = 'spaces'; - const nodes: Node[] = data?.rows.map((row) => mapNode(row)) ?? []; + const nodes: LocalNode[] = data?.rows.map((row) => mapNode(row)) ?? []; return ( ( diff --git a/desktop/src/contexts/workspace.ts b/desktop/src/contexts/workspace.ts index 5bd400f8..2b8f0cbc 100644 --- a/desktop/src/contexts/workspace.ts +++ b/desktop/src/contexts/workspace.ts @@ -2,11 +2,10 @@ import { createContext, useContext } from 'react'; import { Workspace } from '@/types/workspaces'; import { CompiledQuery, Kysely, QueryResult } from 'kysely'; import { WorkspaceDatabaseSchema } from '@/data/schemas/workspace'; -import { LocalMutation } from '@/types/mutations'; interface WorkspaceContext extends Workspace { schema: Kysely; - mutate: (mutation: LocalMutation) => Promise; + mutate: (mutation: CompiledQuery) => Promise; query: (query: CompiledQuery) => Promise>; queryAndSubscribe: ( queryId: string, diff --git a/desktop/src/data/account-manager.ts b/desktop/src/data/account-manager.ts index 4a6ab2a0..1d6e60e7 100644 --- a/desktop/src/data/account-manager.ts +++ b/desktop/src/data/account-manager.ts @@ -103,7 +103,6 @@ export class AccountManager { public async executeEventLoop(): Promise { this.socket.checkConnection(); - for (const workspace of this.workspaces.values()) { if (!workspace.isSynced()) { const synced = await workspace.sync(); diff --git a/desktop/src/data/app-manager.ts b/desktop/src/data/app-manager.ts index f88fa61b..d6f02c44 100644 --- a/desktop/src/data/app-manager.ts +++ b/desktop/src/data/app-manager.ts @@ -35,7 +35,7 @@ class AppManager { this.subscribers = new Map(); const dialect = new SqliteDialect({ - database: buildSqlite(`${this.appPath}/global.db`), + database: buildSqlite(`${this.appPath}/app.db`), }); this.database = new Kysely({ diff --git a/desktop/src/data/migrations/workspace.ts b/desktop/src/data/migrations/workspace.ts index 475f34c6..2590d77f 100644 --- a/desktop/src/data/migrations/workspace.ts +++ b/desktop/src/data/migrations/workspace.ts @@ -1,11 +1,10 @@ -import { Migration } from 'kysely'; +import { Migration, sql } from 'kysely'; const createNodesTable: Migration = { up: async (db) => { await db.schema .createTable('nodes') .addColumn('id', 'text', (col) => col.notNull().primaryKey()) - .addColumn('workspace_id', 'text', (col) => col.notNull()) .addColumn('parent_id', 'text', (col) => col.references('nodes.id').onDelete('cascade'), ) @@ -33,8 +32,10 @@ const createMutationsTable: Migration = { await db.schema .createTable('mutations') .addColumn('id', 'integer', (col) => col.notNull().primaryKey()) - .addColumn('type', 'text', (col) => col.notNull()) - .addColumn('data', 'text', (col) => col.notNull()) + .addColumn('table', 'text', (col) => col.notNull()) + .addColumn('action', 'text', (col) => col.notNull()) + .addColumn('before', 'text') + .addColumn('after', 'text') .addColumn('created_at', 'text', (col) => col.notNull()) .execute(); }, @@ -43,7 +44,139 @@ const createMutationsTable: Migration = { }, }; +const createNodesInsertTrigger: Migration = { + up: async (db) => { + await sql` + CREATE TRIGGER after_insert_nodes + AFTER INSERT ON nodes + FOR EACH ROW + WHEN NEW.server_version_id is null + BEGIN + INSERT INTO mutations ('action', 'table', 'after', 'created_at') + VALUES ( + 'insert', + 'nodes', + json_object( + 'id', NEW.'id', + 'parent_id', NEW.'parent_id', + 'type', NEW.'type', + 'index', NEW.'index', + 'attrs', NEW.'attrs', + 'content', NEW.'content', + 'created_at', NEW.'created_at', + 'updated_at', NEW.'updated_at', + 'created_by', NEW.'created_by', + 'updated_by', NEW.'updated_by', + 'version_id', NEW.'version_id', + 'server_created_at', NEW.'server_created_at', + 'server_updated_at', NEW.'server_updated_at', + 'server_version_id', NEW.'server_version_id' + ), + datetime('now') + ); + END; + `.execute(db); + }, + down: async (db) => { + await sql`DROP TRIGGER after_insert_nodes`.execute(db); + }, +}; + +const createNodesUpdateTrigger: Migration = { + up: async (db) => { + await sql` + CREATE TRIGGER after_update_nodes + AFTER UPDATE ON nodes + FOR EACH ROW + WHEN NEW.version_id != NEW.server_version_id + BEGIN + INSERT INTO mutations ('action', 'table', 'before', 'after', 'created_at') + VALUES ( + 'update', + 'nodes', + json_object( + 'id', OLD.'id', + 'parent_id', OLD.'parent_id', + 'type', OLD.'type', + 'index', OLD.'index', + 'attrs', OLD.'attrs', + 'content', OLD.'content', + 'created_at', OLD.'created_at', + 'updated_at', OLD.'updated_at', + 'created_by', OLD.'created_by', + 'updated_by', OLD.'updated_by', + 'version_id', OLD.'version_id', + 'server_created_at', OLD.'server_created_at', + 'server_updated_at', OLD.'server_updated_at', + 'server_version_id', OLD.'server_version_id' + ), + json_object( + 'id', NEW.'id', + 'parent_id', NEW.'parent_id', + 'type', NEW.'type', + 'index', NEW.'index', + 'attrs', NEW.'attrs', + 'content', NEW.'content', + 'created_at', NEW.'created_at', + 'updated_at', NEW.'updated_at', + 'created_by', NEW.'created_by', + 'updated_by', NEW.'updated_by', + 'version_id', NEW.'version_id', + 'server_created_at', NEW.'server_created_at', + 'server_updated_at', NEW.'server_updated_at', + 'server_version_id', NEW.'server_version_id' + ), + datetime('now') + ); + END; + `.execute(db); + }, + down: async (db) => { + await sql`DROP TRIGGER after_update_nodes`.execute(db); + }, +}; + +const createDeleteNodesTrigger: Migration = { + up: async (db) => { + await sql` + CREATE TRIGGER after_delete_nodes + AFTER DELETE ON nodes + FOR EACH ROW + BEGIN + INSERT INTO mutations ('action', 'table', 'before', 'created_at') + VALUES ( + 'delete', + 'nodes', + json_object( + 'id', OLD.'id', + 'parent_id', OLD.'parent_id', + 'type', OLD.'type', + 'index', OLD.'index', + 'attrs', OLD.'attrs', + 'content', OLD.'content', + 'created_at', OLD.'created_at', + 'updated_at', OLD.'updated_at', + 'created_by', OLD.'created_by', + 'updated_by', OLD.'updated_by', + 'version_id', OLD.'version_id', + 'server_created_at', OLD.'server_created_at', + 'server_updated_at', OLD.'server_updated_at', + 'server_version_id', OLD.'server_version_id' + ), + datetime('now') + ); + END; + `.execute(db); + }, + down: async (db) => { + await sql`DROP TRIGGER after_delete_nodes`.execute(db); + }, +}; + export const workspaceDatabaseMigrations: Record = { '202408011756_create_nodes_table': createNodesTable, '202408011757_create_mutations_table': createMutationsTable, + '202408011758_create_node_insert_trigger': createNodesInsertTrigger, + '202408011759_create_node_update_trigger': createNodesUpdateTrigger, + '202408011760_create_node_delete_trigger': createDeleteNodesTrigger, }; diff --git a/desktop/src/data/mutation-manager.ts b/desktop/src/data/mutation-manager.ts deleted file mode 100644 index 67d3a463..00000000 --- a/desktop/src/data/mutation-manager.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { Kysely } from 'kysely'; -import { - NodesTableSchema, - WorkspaceDatabaseSchema, -} from '@/data/schemas/workspace'; -import { - LocalMutation, - LocalDeleteNodesMutation, - LocalCreateNodeMutation, - LocalUpdateNodeMutation, - LocalDeleteNodeMutation, - LocalCreateNodesMutation, - ServerMutation, - ServerCreateNodeMutation, - ServerCreateNodesMutation, - ServerUpdateNodeMutation, -} from '@/types/mutations'; -import { Workspace } from '@/types/workspaces'; -import { Node } from '@/types/nodes'; -import Axios, { AxiosInstance } from 'axios'; -import { NeuronId } from '@/lib/id'; - -export class MutationManager { - private readonly workspace: Workspace; - private readonly database: Kysely; - private readonly axios: AxiosInstance; - private readonly mutationListeners: Map< - string, - (affectedTables: string[]) => void - >; - - constructor( - axios: AxiosInstance, - workspace: Workspace, - database: Kysely, - ) { - this.workspace = workspace; - this.axios = axios; - this.database = database; - this.mutationListeners = new Map(); - } - - public onMutation(listener: (affectedTables: string[]) => void): void { - const id = NeuronId.generate(NeuronId.Type.Subscriber); - this.mutationListeners.set(id, listener); - } - - private notifyMutationListeners(affectedTables: string[]): void { - for (const listener of this.mutationListeners.values()) { - listener(affectedTables); - } - } - - public async executeLocalMutation(mutation: LocalMutation): Promise { - switch (mutation.type) { - case 'create_node': - return this.executeLocalCreateNodeMutation(mutation); - case 'create_nodes': - return this.executeLocalCreateNodesMutation(mutation); - case 'update_node': - return this.executeLocalUpdateNodeMutation(mutation); - case 'delete_node': - return this.executeLocalDeleteNodeMutation(mutation); - case 'delete_nodes': - return this.executeLocalDeleteNodesMutation(mutation); - } - } - - private async executeLocalCreateNodeMutation( - mutation: LocalCreateNodeMutation, - ): Promise { - await this.database.transaction().execute(async (trx) => { - const node = mutation.data.node; - await trx - .insertInto('nodes') - .values({ - id: node.id, - type: node.type, - parent_id: node.parentId, - workspace_id: node.workspaceId, - index: node.index, - content: node.content ? JSON.stringify(node.content) : null, - attrs: node.attrs ? JSON.stringify(node.attrs) : null, - created_at: node.createdAt, - created_by: node.createdBy, - version_id: node.versionId, - }) - .execute(); - - await trx - .insertInto('mutations') - .values({ - type: mutation.type, - data: JSON.stringify(mutation.data), - created_at: new Date().toISOString(), - }) - .execute(); - }); - - this.notifyMutationListeners(['nodes']); - } - - private async executeLocalCreateNodesMutation( - mutation: LocalCreateNodesMutation, - ): Promise { - await this.database.transaction().execute(async (trx) => { - const data = mutation.data; - await trx - .insertInto('nodes') - .values( - data.nodes.map((node) => ({ - id: node.id, - type: node.type, - parent_id: node.parentId, - workspace_id: node.workspaceId, - index: node.index, - content: node.content ? JSON.stringify(node.content) : null, - attrs: node.attrs ? JSON.stringify(node.attrs) : null, - created_at: node.createdAt, - created_by: node.createdBy, - version_id: node.versionId, - })), - ) - .execute(); - - await trx - .insertInto('mutations') - .values({ - type: mutation.type, - data: JSON.stringify(mutation.data), - created_at: new Date().toISOString(), - }) - .execute(); - }); - - this.notifyMutationListeners(['nodes']); - } - - private async executeLocalUpdateNodeMutation( - mutation: LocalUpdateNodeMutation, - ): Promise { - await this.database.transaction().execute(async (trx) => { - const data = mutation.data; - await trx - .updateTable('nodes') - .set({ - type: data.type, - parent_id: data.parentId, - index: data.index, - content: data.content ? JSON.stringify(data.content) : null, - attrs: data.attrs ? JSON.stringify(data.attrs) : null, - updated_at: data.updatedAt, - updated_by: data.updatedBy, - version_id: data.versionId, - }) - .where('id', '=', data.id) - .execute(); - - await trx - .insertInto('mutations') - .values({ - type: mutation.type, - data: JSON.stringify(mutation.data), - created_at: new Date().toISOString(), - }) - .execute(); - }); - - this.notifyMutationListeners(['nodes']); - } - - private async executeLocalDeleteNodeMutation( - mutation: LocalDeleteNodeMutation, - ): Promise { - await this.database.transaction().execute(async (trx) => { - await trx - .deleteFrom('nodes') - .where('id', '=', mutation.data.id) - .execute(); - - await trx - .insertInto('mutations') - .values({ - type: mutation.type, - data: JSON.stringify(mutation.data), - created_at: new Date().toISOString(), - }) - .execute(); - }); - - this.notifyMutationListeners(['nodes']); - } - - private async executeLocalDeleteNodesMutation( - mutation: LocalDeleteNodesMutation, - ): Promise { - await this.database.transaction().execute(async (trx) => { - await trx - .deleteFrom('nodes') - .where('id', 'in', mutation.data.ids) - .execute(); - - await trx - .insertInto('mutations') - .values({ - type: mutation.type, - data: JSON.stringify(mutation.data), - created_at: new Date().toISOString(), - }) - .execute(); - }); - - this.notifyMutationListeners(['nodes']); - } - - public async executeServerMutation(mutation: ServerMutation): Promise { - switch (mutation.type) { - case 'create_node': - return this.executeServerCreateNodeMutation(mutation); - case 'create_nodes': - return this.executeServerCreateNodesMutation(mutation); - case 'update_node': - return this.executeServerUpdateNodeMutation(mutation); - case 'delete_node': - return this.executeServerDeleteNodeMutation(mutation); - case 'delete_nodes': - return this.executeServerDeleteNodesMutation(mutation); - } - } - - public async executeServerCreateNodeMutation( - mutation: ServerCreateNodeMutation, - ): Promise { - const node = mutation.data.node; - await this.syncNodeFromServer(node); - } - - public async executeServerCreateNodesMutation( - mutation: ServerCreateNodesMutation, - ): Promise { - for (const node of mutation.data.nodes) { - await this.syncNodeFromServer(node); - } - } - - public async executeServerUpdateNodeMutation( - mutation: ServerUpdateNodeMutation, - ): Promise { - await this.syncNodeFromServer(mutation.data.node); - } - - public async executeServerDeleteNodeMutation( - mutation: LocalDeleteNodeMutation, - ): Promise { - await this.database - .deleteFrom('nodes') - .where('id', '=', mutation.data.id) - .execute(); - } - - public async executeServerDeleteNodesMutation( - mutation: LocalDeleteNodesMutation, - ): Promise { - await this.database - .deleteFrom('nodes') - .where('id', 'in', mutation.data.ids) - .execute(); - - this.notifyMutationListeners(['nodes']); - } - - public async syncNodeFromServer(node: Node): Promise { - const existingNode = await this.database - .selectFrom('nodes') - .selectAll() - .where('id', '=', node.id) - .executeTakeFirst(); - - if (!existingNode) { - await this.database - .insertInto('nodes') - .values({ - id: node.id, - type: node.type, - parent_id: node.parentId, - workspace_id: node.workspaceId, - index: node.index, - content: node.content ? JSON.stringify(node.content) : null, - attrs: node.attrs ? JSON.stringify(node.attrs) : null, - created_at: node.createdAt, - created_by: node.createdBy, - updated_by: node.updatedBy, - updated_at: node.updatedAt, - version_id: node.versionId, - server_created_at: node.serverCreatedAt, - server_updated_at: node.serverUpdatedAt, - server_version_id: node.serverVersionId, - }) - .execute(); - - this.notifyMutationListeners(['nodes']); - return; - } - - if (this.shouldUpdateNodeFromServer(existingNode, node)) { - await this.database - .updateTable('nodes') - .set({ - type: node.type, - parent_id: node.parentId, - index: node.index, - content: node.content ? JSON.stringify(node.content) : null, - attrs: node.attrs ? JSON.stringify(node.attrs) : null, - 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.serverVersionId, - }) - .where('id', '=', node.id) - .execute(); - - this.notifyMutationListeners(['nodes']); - } - } - - public shouldUpdateNodeFromServer( - localNode: NodesTableSchema, - serverNode: Node, - ): boolean { - if (localNode.server_version_id === serverNode.serverVersionId) { - return false; - } - - if (localNode.updated_at) { - if (!serverNode.updatedAt) { - return false; - } - - const localUpdatedAt = new Date(localNode.updated_at); - const serverUpdatedAt = new Date(serverNode.updatedAt); - - if (localUpdatedAt > serverUpdatedAt) { - return false; - } - } - - return true; - } - - public async sendMutations(): Promise { - do { - const nextMutation = await this.database - .selectFrom('mutations') - .selectAll() - .orderBy('id') - .limit(1) - .executeTakeFirst(); - - if (!nextMutation) { - break; - } - - try { - const { data, status } = await this.axios.post( - 'v1/mutations', - { - workspaceId: this.workspace.id, - type: nextMutation.type, - data: JSON.parse(nextMutation.data), - }, - ); - - if (status === 200) { - await this.database - .deleteFrom('mutations') - .where('id', '=', nextMutation.id) - .execute(); - - await this.executeServerMutation(data); - } - } catch (error) { - if (Axios.isAxiosError(error)) { - if (error.response?.status === 404) { - await this.database - .deleteFrom('mutations') - .where('id', '=', nextMutation.id) - .execute(); - } - } - } - } while (true); - } -} diff --git a/desktop/src/data/schemas/app.ts b/desktop/src/data/schemas/app.ts index e5369d92..64bc247c 100644 --- a/desktop/src/data/schemas/app.ts +++ b/desktop/src/data/schemas/app.ts @@ -1,25 +1,35 @@ -export interface AccountsTableSchema { - id: string; - name: string; - email: string; - avatar: string | null; - token: string; - device_id: string; +import { ColumnType, Insertable, Selectable, Updateable } from 'kysely'; + +interface AccountTable { + id: ColumnType; + name: ColumnType; + email: ColumnType; + avatar: ColumnType; + token: ColumnType; + device_id: ColumnType; } -export interface WorkspacesTableSchema { - id: string; - account_id: string; - name: string; - description: string | null; - avatar: string | null; - version_id: string; - role: number; - user_id: string; - synced: number; +export type SelectAccount = Selectable; +export type CreateAccount = Insertable; +export type UpdateAccount = Updateable; + +interface WorkspaceTable { + id: ColumnType; + account_id: ColumnType; + name: ColumnType; + description: ColumnType; + avatar: ColumnType; + version_id: ColumnType; + role: ColumnType; + user_id: ColumnType; + synced: ColumnType; } +export type SelectWorkspace = Selectable; +export type CreateWorkspace = Insertable; +export type UpdateWorkspace = Updateable; + export interface AppDatabaseSchema { - accounts: AccountsTableSchema; - workspaces: WorkspacesTableSchema; + accounts: AccountTable; + workspaces: WorkspaceTable; } diff --git a/desktop/src/data/schemas/workspace.ts b/desktop/src/data/schemas/workspace.ts index 48178fd5..a52140a9 100644 --- a/desktop/src/data/schemas/workspace.ts +++ b/desktop/src/data/schemas/workspace.ts @@ -1,29 +1,40 @@ -export interface NodesTableSchema { - id: string; - workspace_id: string; - parent_id: string | null; - type: string; - index: string | null; - attrs: string | null; - content: string | null; - created_at: string; - updated_at: string | null; - created_by: string; - updated_by: string | null; - version_id: string; - server_created_at: string | null; - server_updated_at: string | null; - server_version_id: string | null; +import { ColumnType, Insertable, Selectable, Updateable } from 'kysely'; + +interface NodeTable { + id: ColumnType; + parent_id?: ColumnType; + type: ColumnType; + index: ColumnType; + attrs: ColumnType; + content: ColumnType; + created_at: ColumnType; + updated_at?: ColumnType; + created_by: ColumnType; + updated_by?: ColumnType; + version_id: ColumnType; + server_created_at?: ColumnType; + server_updated_at?: ColumnType; + server_version_id?: ColumnType; } -export interface MutationsTableSchema { - id: number; - type: string; - data: string; - created_at: string; +export type SelectNode = Selectable; +export type CreateNode = Insertable; +export type UpdateNode = Updateable; + +interface MutationTable { + id: ColumnType; + table: ColumnType; + action: ColumnType; + before: ColumnType; + after: ColumnType; + created_at: ColumnType; } +export type SelectMutation = Selectable; +export type CreateMutation = Insertable; +export type UpdateMutation = Updateable; + export interface WorkspaceDatabaseSchema { - nodes: NodesTableSchema; - mutations: MutationsTableSchema; + nodes: NodeTable; + mutations: MutationTable; } diff --git a/desktop/src/data/workspace-manager.ts b/desktop/src/data/workspace-manager.ts index 14012e6b..53ee4f16 100644 --- a/desktop/src/data/workspace-manager.ts +++ b/desktop/src/data/workspace-manager.ts @@ -16,17 +16,21 @@ import { resultHasChanged, } from '@/data/utils'; import { SubscribedQueryData } from '@/types/databases'; -import { LocalMutation, ServerMutation } from '@/types/mutations'; +import { ServerMutation } from '@/types/mutations'; import { eventBus } from '@/lib/event-bus'; -import { MutationManager } from '@/data/mutation-manager'; import { AxiosInstance } from 'axios'; +import { debounce } from 'lodash'; +import { ServerNode } from '@/types/nodes'; +import { SelectNode } from '@/data/schemas/workspace'; export class WorkspaceManager { private readonly workspace: Workspace; private readonly axios: AxiosInstance; private readonly database: Kysely; private readonly subscribers: Map>; - private readonly mutationManager: MutationManager; + private readonly debouncedNotifyQuerySubscribers: ( + affectedTables: string[], + ) => void; constructor(workspace: Workspace, axios: AxiosInstance, accountPath: string) { this.workspace = workspace; @@ -46,33 +50,10 @@ export class WorkspaceManager { dialect, }); - this.mutationManager = new MutationManager(axios, workspace, this.database); - - this.mutationManager.onMutation(async (affectedTables) => { - for (const [subscriberId, subscriberData] of this.subscribers) { - const hasAffectedTables = subscriberData.tables.some((table) => - affectedTables.includes(table), - ); - - if (!hasAffectedTables) { - continue; - } - - const newResult = await this.database.executeQuery( - subscriberData.query, - ); - - if (resultHasChanged(subscriberData.result, newResult)) { - eventBus.publish({ - event: 'workspace_query_updated', - payload: { - queryId: subscriberId, - result: newResult, - }, - }); - } - } - }); + this.debouncedNotifyQuerySubscribers = debounce( + this.notifyQuerySubscribers, + 100, + ); } public getWorkspace(): Workspace { @@ -118,20 +99,96 @@ export class WorkspaceManager { return result; } + public async executeMutation(mutation: CompiledQuery): Promise { + const result = await this.database.executeQuery(mutation); + + if (result.numAffectedRows === 0n) { + return; + } + + const affectedTables = extractTablesFromSql(mutation.sql); + if (affectedTables.length > 0) { + this.debouncedNotifyQuerySubscribers(affectedTables); + } + } + public unsubscribeQuery(queryId: string): void { this.subscribers.delete(queryId); } - public async executeLocalMutation(mutation: LocalMutation): Promise { - await this.mutationManager.executeLocalMutation(mutation); - } + private async notifyQuerySubscribers( + affectedTables: string[], + ): Promise { + for (const [subscriberId, subscriberData] of this.subscribers) { + const hasAffectedTables = subscriberData.tables.some((table) => + affectedTables.includes(table), + ); - public async executeServerMutation(mutation: ServerMutation): Promise { - await this.mutationManager.executeServerMutation(mutation); + if (!hasAffectedTables) { + continue; + } + + const newResult = await this.database.executeQuery(subscriberData.query); + + if (resultHasChanged(subscriberData.result, newResult)) { + eventBus.publish({ + event: 'workspace_query_updated', + payload: { + queryId: subscriberId, + result: newResult, + }, + }); + } + } } public async sendMutations(): Promise { - await this.mutationManager.sendMutations(); + const mutations = await this.database + .selectFrom('mutations') + .selectAll() + .orderBy('id asc') + .limit(20) + .execute(); + + if (!mutations || mutations.length === 0) { + return; + } + + try { + const { status } = await this.axios.post('v1/mutations', { + workspaceId: this.workspace.id, + mutations: mutations, + }); + + if (status === 200) { + const mutationIds = mutations.map((mutation) => mutation.id); + await this.database + .deleteFrom('mutations') + .where('id', 'in', mutationIds) + .execute(); + } + } catch (error) { + console.error(error); + } + } + + public async executeServerMutation(mutation: ServerMutation): Promise { + if (mutation.table === 'nodes') { + if (mutation.action === 'insert' || mutation.action === 'update') { + const node = JSON.parse(mutation.after) as ServerNode; + await this.syncNodeFromServer(node); + } else if (mutation.action === 'delete') { + const node = JSON.parse(mutation.before) as ServerNode; + await this.database + .deleteFrom('nodes') + .where('id', '=', node.id) + .execute(); + + this.debouncedNotifyQuerySubscribers(['nodes']); + } + } + + //other cases in the future } public isSynced(): boolean { @@ -152,13 +209,93 @@ export class WorkspaceManager { } for (const node of data.nodes) { - await this.mutationManager.syncNodeFromServer(node); + await this.syncNodeFromServer(node); } this.workspace.synced = true; return true; } + public async syncNodeFromServer(node: ServerNode): Promise { + console.log('Syncing node from server:', node); + const existingNode = await this.database + .selectFrom('nodes') + .selectAll() + .where('id', '=', node.id) + .executeTakeFirst(); + + if (!existingNode) { + await this.database + .insertInto('nodes') + .values({ + id: node.id, + type: node.type, + parent_id: node.parentId, + index: node.index, + content: node.content ? JSON.stringify(node.content) : null, + attrs: node.attrs ? JSON.stringify(node.attrs) : null, + created_at: node.createdAt, + created_by: node.createdBy, + updated_by: node.updatedBy, + updated_at: node.updatedAt, + version_id: node.versionId, + server_created_at: node.serverCreatedAt, + server_updated_at: node.serverUpdatedAt, + server_version_id: node.versionId, + }) + .execute(); + + this.debouncedNotifyQuerySubscribers(['nodes']); + return; + } + + if (this.shouldUpdateNodeFromServer(existingNode, node)) { + await this.database + .updateTable('nodes') + .set({ + type: node.type, + parent_id: node.parentId, + index: node.index, + content: node.content ? JSON.stringify(node.content) : null, + attrs: node.attrs ? JSON.stringify(node.attrs) : null, + 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, + }) + .where('id', '=', node.id) + .execute(); + + this.debouncedNotifyQuerySubscribers(['nodes']); + } + } + + public shouldUpdateNodeFromServer( + localNode: SelectNode, + serverNode: ServerNode, + ): boolean { + if (localNode.server_version_id === serverNode.versionId) { + return false; + } + + if (localNode.updated_at) { + if (!serverNode.updatedAt) { + return false; + } + + const localUpdatedAt = new Date(localNode.updated_at); + const serverUpdatedAt = new Date(serverNode.updatedAt); + + if (localUpdatedAt > serverUpdatedAt) { + return false; + } + } + + return true; + } + private async migrate(): Promise { const migrator = new Migrator({ db: this.database, diff --git a/desktop/src/editor/renderers/blockquote.tsx b/desktop/src/editor/renderers/blockquote.tsx index 8f856003..55792e1c 100644 --- a/desktop/src/editor/renderers/blockquote.tsx +++ b/desktop/src/editor/renderers/blockquote.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { defaultClasses } from '@/editor/classes'; import { NodeChildrenRenderer } from '@/editor/renderers/node-children'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; interface BlockquoteRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/bullet-list.tsx b/desktop/src/editor/renderers/bullet-list.tsx index 826a3a2f..fdb9d9a6 100644 --- a/desktop/src/editor/renderers/bullet-list.tsx +++ b/desktop/src/editor/renderers/bullet-list.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { defaultClasses } from '@/editor/classes'; import { NodeChildrenRenderer } from '@/editor/renderers/node-children'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; interface BulletListRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/code-block.tsx b/desktop/src/editor/renderers/code-block.tsx index a400ca67..28765dc8 100644 --- a/desktop/src/editor/renderers/code-block.tsx +++ b/desktop/src/editor/renderers/code-block.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { Icon } from '@/components/ui/icon'; import { defaultClasses } from '@/editor/classes'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; import { highlightCode, languages } from '@/lib/lowlight'; interface CodeBlockRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/document.tsx b/desktop/src/editor/renderers/document.tsx index 30dcd477..fa90a32c 100644 --- a/desktop/src/editor/renderers/document.tsx +++ b/desktop/src/editor/renderers/document.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { NodeChildrenRenderer } from '@/editor/renderers/node-children'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; interface DocumentRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/heading.tsx b/desktop/src/editor/renderers/heading.tsx index 6fda8cf0..0c7e653d 100644 --- a/desktop/src/editor/renderers/heading.tsx +++ b/desktop/src/editor/renderers/heading.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { defaultClasses } from '@/editor/classes'; import { NodeChildrenRenderer } from '@/editor/renderers/node-children'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; interface HeadingRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/list-item.tsx b/desktop/src/editor/renderers/list-item.tsx index e14f21e8..5389edc9 100644 --- a/desktop/src/editor/renderers/list-item.tsx +++ b/desktop/src/editor/renderers/list-item.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { defaultClasses } from '@/editor/classes'; import { NodeChildrenRenderer } from '@/editor/renderers/node-children'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; interface ListItemRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/message.tsx b/desktop/src/editor/renderers/message.tsx index 9844e549..2bafae83 100644 --- a/desktop/src/editor/renderers/message.tsx +++ b/desktop/src/editor/renderers/message.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { NodeChildrenRenderer } from '@/editor/renderers/node-children'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; interface MessageRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/node-children.tsx b/desktop/src/editor/renderers/node-children.tsx index 87c56be8..b104cd94 100644 --- a/desktop/src/editor/renderers/node-children.tsx +++ b/desktop/src/editor/renderers/node-children.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; import { NodeBlockRenderer } from '@/editor/renderers/node-block'; import { NodeRenderer } from '@/editor/renderers/node'; interface NodeChildrenRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/node.tsx b/desktop/src/editor/renderers/node.tsx index bf495947..4c83ac97 100644 --- a/desktop/src/editor/renderers/node.tsx +++ b/desktop/src/editor/renderers/node.tsx @@ -11,11 +11,11 @@ import { ParagraphRenderer } from '@/editor/renderers/paragraph'; import { TaskItemRenderer } from '@/editor/renderers/task-item'; import { TaskListRenderer } from '@/editor/renderers/task-list'; import { TextRenderer } from '@/editor/renderers/text'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; import { match } from 'ts-pattern'; interface NodeRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/ordered-list.tsx b/desktop/src/editor/renderers/ordered-list.tsx index 6af815e5..6df470dd 100644 --- a/desktop/src/editor/renderers/ordered-list.tsx +++ b/desktop/src/editor/renderers/ordered-list.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { defaultClasses } from '@/editor/classes'; import { NodeChildrenRenderer } from '@/editor/renderers/node-children'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; interface OrderedListRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/paragraph.tsx b/desktop/src/editor/renderers/paragraph.tsx index 0655eb8f..08a94a8c 100644 --- a/desktop/src/editor/renderers/paragraph.tsx +++ b/desktop/src/editor/renderers/paragraph.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { defaultClasses } from '@/editor/classes'; import { NodeChildrenRenderer } from '@/editor/renderers/node-children'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; import { cn } from '@/lib/utils'; interface ParagraphRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/task-item.tsx b/desktop/src/editor/renderers/task-item.tsx index 2f6532f0..09f3b1c8 100644 --- a/desktop/src/editor/renderers/task-item.tsx +++ b/desktop/src/editor/renderers/task-item.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { defaultClasses } from '@/editor/classes'; import { NodeChildrenRenderer } from '@/editor/renderers/node-children'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; interface TaskItemRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/renderers/task-list.tsx b/desktop/src/editor/renderers/task-list.tsx index aeba6d84..1e14023b 100644 --- a/desktop/src/editor/renderers/task-list.tsx +++ b/desktop/src/editor/renderers/task-list.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { defaultClasses } from '@/editor/classes'; import { NodeChildrenRenderer } from '@/editor/renderers/node-children'; -import { NodeWithChildren } from '@/types/nodes'; +import { LocalNodeWithChildren } from '@/types/nodes'; interface TaskListRendererProps { - node: NodeWithChildren; + node: LocalNodeWithChildren; keyPrefix: string | null; } diff --git a/desktop/src/editor/utils.ts b/desktop/src/editor/utils.ts index 7790f0ff..c8512f56 100644 --- a/desktop/src/editor/utils.ts +++ b/desktop/src/editor/utils.ts @@ -1,4 +1,4 @@ -import { Node, NodeBlock } from '@/types/nodes'; +import { LocalNode, NodeBlock } from '@/types/nodes'; import { JSONContent } from '@tiptap/core'; import { NeuronId } from '@/lib/id'; import { EditorNode } from '@/types/editor'; @@ -7,7 +7,7 @@ import { isEqual } from 'lodash'; import { generateNodeIndex } from '@/lib/nodes'; export const mapNodesToEditorNodes = ( - nodes: Node[], + nodes: LocalNode[], ): Map => { const editorNodes = new Map(); for (const node of nodes) { @@ -18,7 +18,7 @@ export const mapNodesToEditorNodes = ( return editorNodes; }; -export const mapNodeToEditorNode = (node: Node): EditorNode => { +export const mapNodeToEditorNode = (node: LocalNode): EditorNode => { return { id: node.id, type: node.type, diff --git a/desktop/src/hooks/use-conversation.tsx b/desktop/src/hooks/use-conversation.tsx index 603f4bdc..7f4e144f 100644 --- a/desktop/src/hooks/use-conversation.tsx +++ b/desktop/src/hooks/use-conversation.tsx @@ -1,4 +1,4 @@ -import { Node, NodeBlock } from '@/types/nodes'; +import { LocalNode, NodeBlock } from '@/types/nodes'; import { useWorkspace } from '@/contexts/workspace'; import { JSONContent } from '@tiptap/core'; import { NeuronId } from '@/lib/id'; @@ -6,10 +6,9 @@ import { LeafNodeTypes, NodeTypes } from '@/lib/constants'; import { buildNodeWithChildren, generateNodeIndex, mapNode } from '@/lib/nodes'; import { useQuery } from '@tanstack/react-query'; import { sql } from 'kysely'; -import { NodesTableSchema } from '@/data/schemas/workspace'; +import { CreateNode, SelectNode } from '@/data/schemas/workspace'; import { hashCode } from '@/lib/utils'; import { MessageNode } from '@/types/messages'; -import { LocalCreateNodesMutation } from '@/types/mutations'; interface useConversationResult { isLoading: boolean; @@ -28,7 +27,7 @@ export const useConversation = ( const messagesQuery = useQuery({ queryKey: [`conversation:messages:${conversationId}`], queryFn: async ({ queryKey }) => { - const query = sql` + const query = sql` WITH RECURSIVE conversation_hierarchy AS ( SELECT * FROM nodes @@ -74,22 +73,21 @@ export const useConversation = ( }); const createMessage = async (content: JSONContent) => { - const mutation: LocalCreateNodesMutation = { - type: 'create_nodes', - data: { - nodes: [], - }, - }; + const nodesToInsert: CreateNode[] = []; buildMessageCreateNodes( - mutation, + nodesToInsert, workspace.userId, workspace.id, conversationId, content, ); - await workspace.mutate(mutation); + const query = workspace.schema + .insertInto('nodes') + .values(nodesToInsert) + .compile(); + await workspace.mutate(query); }; const conversationNodes = @@ -107,9 +105,12 @@ export const useConversation = ( }; }; -const buildMessages = (nodes: Node[], authors: Node[]): MessageNode[] => { +const buildMessages = ( + nodes: LocalNode[], + authors: LocalNode[], +): MessageNode[] => { const messages: MessageNode[] = []; - const authorMap = new Map(); + const authorMap = new Map(); for (const author of authors) { authorMap.set(author.id, author); @@ -142,7 +143,7 @@ const buildMessages = (nodes: Node[], authors: Node[]): MessageNode[] => { }; const buildMessageCreateNodes = ( - mutation: LocalCreateNodesMutation, + nodes: CreateNode[], userId: string, workspaceId: string, parentId: string, @@ -183,31 +184,23 @@ const buildMessageCreateNodes = ( nodeContent = null; } - mutation.data.nodes.push({ + nodes.push({ id: id, - parentId, - workspaceId: workspaceId, + parent_id: parentId, type: content.type, - attrs: attrs, + attrs: attrs ? JSON.stringify(attrs) : null, index: index, - content: nodeContent, - createdAt: new Date().toISOString(), - createdBy: userId, - versionId: NeuronId.generate(NeuronId.Type.Version), + content: nodeContent ? JSON.stringify(nodeContent) : null, + created_at: new Date().toISOString(), + created_by: userId, + version_id: NeuronId.generate(NeuronId.Type.Version), }); if (nodeContent == null && content.content && content.content.length > 0) { let lastIndex: string | null = null; for (const child of content.content) { lastIndex = generateNodeIndex(lastIndex, null); - buildMessageCreateNodes( - mutation, - userId, - workspaceId, - id, - child, - lastIndex, - ); + buildMessageCreateNodes(nodes, userId, workspaceId, id, child, lastIndex); } } }; diff --git a/desktop/src/lib/nodes.ts b/desktop/src/lib/nodes.ts index 0f921f6e..1a3c63ba 100644 --- a/desktop/src/lib/nodes.ts +++ b/desktop/src/lib/nodes.ts @@ -1,12 +1,12 @@ -import { NodesTableSchema } from '@/data/schemas/workspace'; -import { Node, NodeWithChildren } from '@/types/nodes'; +import { SelectNode } from '@/data/schemas/workspace'; +import { LocalNode, LocalNodeWithChildren } from '@/types/nodes'; import { generateKeyBetween } from 'fractional-indexing-jittered'; export const buildNodeWithChildren = ( - node: Node, - allNodes: Node[], -): NodeWithChildren => { - const children: NodeWithChildren[] = allNodes + node: LocalNode, + allNodes: LocalNode[], +): LocalNodeWithChildren => { + const children: LocalNodeWithChildren[] = allNodes .filter((n) => n.parentId === node.id) .map((n) => buildNodeWithChildren(n, allNodes)); @@ -26,15 +26,14 @@ export const generateNodeIndex = ( return generateKeyBetween(lower, upper); }; -export const mapNode = (row: NodesTableSchema): Node => { +export const mapNode = (row: SelectNode): LocalNode => { return { id: row.id, type: row.type, index: row.index, parentId: row.parent_id, - workspaceId: row.workspace_id, - attrs: row.attrs && JSON.parse(row.attrs), - content: row.content && JSON.parse(row.content), + attrs: row.attrs ? JSON.parse(row.attrs) : null, + content: row.content ? JSON.parse(row.content) : null, createdAt: row.created_at, createdBy: row.created_by, updatedAt: row.updated_at, diff --git a/desktop/src/main.ts b/desktop/src/main.ts index ec0f9ad4..7fb08058 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -3,7 +3,6 @@ import path from 'path'; import { eventBus } from '@/lib/event-bus'; import { appManager } from '@/data/app-manager'; import { CompiledQuery, QueryResult } from 'kysely'; -import { LocalMutation } from '@/types/mutations'; let subscriptionId: string | null = null; @@ -105,7 +104,7 @@ ipcMain.handle( _, accountId: string, workspaceId: string, - mutation: LocalMutation, + mutation: CompiledQuery, ): Promise => { const accountManager = await appManager.getAccount(accountId); if (!accountManager) { @@ -117,7 +116,7 @@ ipcMain.handle( throw new Error(`Workspace not found: ${workspaceId}`); } - return await workspaceManager.executeLocalMutation(mutation); + return await workspaceManager.executeMutation(mutation); }, ); diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 2b9be6c2..38415597 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -1,7 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron'; import { eventBus, Event } from '@/lib/event-bus'; import { CompiledQuery, QueryResult } from 'kysely'; -import { LocalMutation } from '@/types/mutations'; contextBridge.exposeInMainWorld('neuron', { init: () => ipcRenderer.invoke('init'), @@ -23,7 +22,7 @@ contextBridge.exposeInMainWorld('neuron', { executeWorkspaceMutation: ( accountId: string, workspaceId: string, - mutation: LocalMutation, + mutation: CompiledQuery, ): Promise => ipcRenderer.invoke( 'execute-workspace-mutation', diff --git a/desktop/src/types/messages.ts b/desktop/src/types/messages.ts index 49df0f72..56e05711 100644 --- a/desktop/src/types/messages.ts +++ b/desktop/src/types/messages.ts @@ -1,5 +1,5 @@ -import { Node, NodeWithChildren } from '@/types/nodes'; +import { LocalNode, LocalNodeWithChildren } from '@/types/nodes'; -export type MessageNode = NodeWithChildren & { - author?: Node | null; +export type MessageNode = LocalNodeWithChildren & { + author?: LocalNode | null; }; diff --git a/desktop/src/types/mutations.ts b/desktop/src/types/mutations.ts index c3e0224e..4ef6c246 100644 --- a/desktop/src/types/mutations.ts +++ b/desktop/src/types/mutations.ts @@ -1,121 +1,8 @@ -import { Node, NodeBlock } from '@/types/nodes'; - -export type LocalMutation = - | LocalCreateNodeMutation - | LocalCreateNodesMutation - | LocalUpdateNodeMutation - | LocalDeleteNodeMutation - | LocalDeleteNodesMutation; - -export type LocalCreateNodeMutation = { - type: 'create_node'; - data: { - node: LocalCreateNodeData; - }; -}; - -export type LocalCreateNodesMutation = { - type: 'create_nodes'; - data: { - nodes: LocalCreateNodeData[]; - }; -}; - -export type LocalCreateNodeData = { +export type ServerMutation = { id: string; - type: string; - parentId: string | null; + table: string; + action: string; workspaceId: string; - index: string | null; - content: NodeBlock[]; - attrs: Record; - createdAt: string; - createdBy: string; - versionId: string; -}; - -export type LocalUpdateNodeMutation = { - type: 'update_node'; - data: { - id: string; - type: string; - parentId: string | null; - index: string | null; - content: NodeBlock[]; - attrs: Record; - updatedAt: string; - updatedBy: string; - versionId: string; - }; -}; - -export type LocalDeleteNodeMutation = { - type: 'delete_node'; - data: { - id: string; - }; -}; - -export type LocalDeleteNodesMutation = { - type: 'delete_nodes'; - data: { - ids: string[]; - }; -}; - -export type ServerMutation = - | ServerCreateNodeMutation - | ServerCreateNodesMutation - | ServerUpdateNodeMutation - | ServerDeleteNodeMutation - | ServerDeleteNodesMutation; - -export type ServerCreateNodeMutation = { - id: string; - type: 'create_node'; - workspaceId: string; - data: { - node: Node; - }; - createdAt: string; -}; - -export type ServerCreateNodesMutation = { - id: string; - type: 'create_nodes'; - workspaceId: string; - data: { - nodes: Node[]; - }; - createdAt: string; -}; - -export type ServerUpdateNodeMutation = { - id: string; - type: 'update_node'; - workspaceId: string; - data: { - node: Node; - }; - createdAt: string; -}; - -export type ServerDeleteNodeMutation = { - id: string; - type: 'delete_node'; - workspaceId: string; - data: { - id: string; - }; - createdAt: string; -}; - -export type ServerDeleteNodesMutation = { - id: string; - type: 'delete_nodes'; - workspaceId: string; - data: { - ids: string[]; - }; - createdAt: string; + before: any | null; + after: any | null; }; diff --git a/desktop/src/types/nodes.ts b/desktop/src/types/nodes.ts index baff0517..9875ae05 100644 --- a/desktop/src/types/nodes.ts +++ b/desktop/src/types/nodes.ts @@ -1,6 +1,5 @@ -export type Node = { +export type LocalNode = { id: string; - workspaceId: string; parentId?: string | null; type: string; index?: string | null; @@ -27,6 +26,22 @@ export type NodeBlockMark = { attrs: any; }; -export type NodeWithChildren = Node & { - children: NodeWithChildren[]; +export type LocalNodeWithChildren = LocalNode & { + children: LocalNodeWithChildren[]; +}; + +export type ServerNode = { + id: string; + parentId?: string | null; + type: string; + index?: string | null; + attrs?: Record | null; + content?: NodeBlock[] | null; + createdAt: string; + createdBy: string; + updatedAt?: string | null; + updatedBy?: string | null; + versionId: string; + serverCreatedAt?: string | null; + serverUpdatedAt?: string | null; }; diff --git a/desktop/src/types/window.d.ts b/desktop/src/types/window.d.ts index 3535097c..f0ca944a 100644 --- a/desktop/src/types/window.d.ts +++ b/desktop/src/types/window.d.ts @@ -5,7 +5,7 @@ import { LocalMutationInput } from '@/types/mutations'; interface NeuronApi { init: () => Promise; logout: (accountId: string) => Promise; - executeAppMutation: (mutation: LocalMutationInput) => Promise; + executeAppMutation: (mutation: CompiledQuery) => Promise; executeAppQuery: (query: CompiledQuery) => Promise>; executeAppQueryAndSubscribe: ( queryId: string, @@ -16,7 +16,7 @@ interface NeuronApi { executeWorkspaceMutation: ( accountId: string, workspaceId: string, - input: LocalMutationInput, + mutation: CompiledQuery, ) => Promise; executeWorkspaceQuery: ( diff --git a/desktop/src/types/workspaces.ts b/desktop/src/types/workspaces.ts index 8375c5f6..05da5c4a 100644 --- a/desktop/src/types/workspaces.ts +++ b/desktop/src/types/workspaces.ts @@ -1,4 +1,4 @@ -import { Node } from '@/types/nodes'; +import { LocalNode } from '@/types/nodes'; export enum WorkspaceRole { Owner = 1, @@ -20,5 +20,5 @@ export type Workspace = { }; export type WorkspaceSyncData = { - nodes: Node[]; + nodes: LocalNode[]; }; diff --git a/server/package-lock.json b/server/package-lock.json index cf4219f3..a2585161 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,14 +9,15 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@prisma/client": "^5.17.0", "@types/cors": "^2.8.17", - "axios": "^1.7.2", + "axios": "^1.7.5", "bcrypt": "^5.1.1", "cors": "^2.8.5", "express": "^4.19.2", "jsonwebtoken": "^9.0.2", "kafkajs": "^2.2.4", + "kysely": "^0.27.4", + "pg": "^8.12.0", "postgres": "^3.4.4", "redis": "^4.7.0", "ulid": "^2.3.0", @@ -26,12 +27,12 @@ "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.6", - "@types/node": "^22.0.0", + "@types/node": "^22.5.1", + "@types/pg": "^8.11.7", "@types/ws": "^8.5.12", "concurrently": "^8.2.2", "nodemon": "^3.1.4", "prettier": "^3.3.3", - "prisma": "^5.17.0", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.5.4" @@ -111,74 +112,6 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, - "node_modules/@prisma/client": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.17.0.tgz", - "integrity": "sha512-N2tnyKayT0Zf7mHjwEyE8iG7FwTmXDHFZ1GnNhQp0pJUObsuel4ZZ1XwfuAYkq5mRIiC/Kot0kt0tGCfLJ70Jw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.13" - }, - "peerDependencies": { - "prisma": "*" - }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - } - } - }, - "node_modules/@prisma/debug": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.17.0.tgz", - "integrity": "sha512-l7+AteR3P8FXiYyo496zkuoiJ5r9jLQEdUuxIxNCN1ud8rdbH3GTxm+f+dCyaSv9l9WY+29L9czaVRXz9mULfg==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/engines": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.17.0.tgz", - "integrity": "sha512-+r+Nf+JP210Jur+/X8SIPLtz+uW9YA4QO5IXA+KcSOBe/shT47bCcRMTYCbOESw3FFYFTwe7vU6KTWHKPiwvtg==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "5.17.0", - "@prisma/engines-version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", - "@prisma/fetch-engine": "5.17.0", - "@prisma/get-platform": "5.17.0" - } - }, - "node_modules/@prisma/engines-version": { - "version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053.tgz", - "integrity": "sha512-tUuxZZysZDcrk5oaNOdrBnnkoTtmNQPkzINFDjz7eG6vcs9AVDmA/F6K5Plsb2aQc/l5M2EnFqn3htng9FA4hg==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/fetch-engine": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.17.0.tgz", - "integrity": "sha512-ESxiOaHuC488ilLPnrv/tM2KrPhQB5TRris/IeIV4ZvUuKeaicCl4Xj/JCQeG9IlxqOgf1cCg5h5vAzlewN91Q==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "5.17.0", - "@prisma/engines-version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", - "@prisma/get-platform": "5.17.0" - } - }, - "node_modules/@prisma/get-platform": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.17.0.tgz", - "integrity": "sha512-UlDgbRozCP1rfJ5Tlkf3Cnftb6srGrEQ4Nm3og+1Se2gWmCZ0hmPIi+tQikGDUVLlvOWx3Gyi9LzgRP+HTXV9w==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "5.17.0" - } - }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -357,12 +290,86 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.0.0.tgz", - "integrity": "sha512-VT7KSYudcPOzP5Q0wfbowyNLaVR8QWUdw+088uFWwfvpY6uCWaXpqV6ieLAu9WBcnTa7H4Z5RLK8I5t2FuOcqw==", + "version": "22.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.1.tgz", + "integrity": "sha512-KkHsxej0j9IW1KKOOAA/XBA0z08UFSrRQHErzEfA3Vgq57eXIMYboIlHJuYIfd+lwCQjtKqUu3UnmKbtUc9yRw==", "license": "MIT", "dependencies": { - "undici-types": "~6.11.1" + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/pg": { + "version": "8.11.7", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.7.tgz", + "integrity": "sha512-k4VGqp1LQzJP5pSK2TR3bJGr9CnErXeRubUEtJu+l1hFGbUwKyMZEjLAlWpfGkrZAatz0O4jP4YPqDepv8CRbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, + "node_modules/@types/pg/node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/pg/node_modules/postgres-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/pg/node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" } }, "node_modules/@types/qs": { @@ -571,9 +578,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz", + "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -1699,6 +1706,15 @@ "node": ">=14.0.0" } }, + "node_modules/kysely": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.4.tgz", + "integrity": "sha512-dyNKv2KRvYOQPLCAOCjjQuCk4YFd33BvGdf/o5bC7FiW+BB6snA81Zt+2wT9QDFzKqxKa5rrOmvlK/anehCcgA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -2058,6 +2074,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2103,6 +2126,105 @@ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", + "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.6.4", + "pg-pool": "^3.6.2", + "pg-protocol": "^1.6.1", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", + "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-pool": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", + "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", + "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -2129,6 +2251,52 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-range": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", + "dev": true, + "license": "MIT" + }, "node_modules/prettier": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", @@ -2145,23 +2313,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prisma": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.17.0.tgz", - "integrity": "sha512-m4UWkN5lBE6yevqeOxEvmepnL5cNPEjzMw2IqDB59AcEV6w7D8vGljDLd1gPFH+W6gUxw9x7/RmN5dCS/WTPxA==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/engines": "5.17.0" - }, - "bin": { - "prisma": "build/index.js" - }, - "engines": { - "node": ">=16.13" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2479,6 +2630,15 @@ "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", "dev": true }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -2721,9 +2881,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.11.1.tgz", - "integrity": "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ==", + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, "node_modules/unpipe": { @@ -2836,6 +2996,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/server/package.json b/server/package.json index a20e7444..57551da0 100644 --- a/server/package.json +++ b/server/package.json @@ -16,25 +16,26 @@ "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.6", - "@types/node": "^22.0.0", + "@types/node": "^22.5.1", + "@types/pg": "^8.11.7", "@types/ws": "^8.5.12", "concurrently": "^8.2.2", "nodemon": "^3.1.4", "prettier": "^3.3.3", - "prisma": "^5.17.0", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.5.4" }, "dependencies": { - "@prisma/client": "^5.17.0", "@types/cors": "^2.8.17", - "axios": "^1.7.2", + "axios": "^1.7.5", "bcrypt": "^5.1.1", "cors": "^2.8.5", "express": "^4.19.2", "jsonwebtoken": "^9.0.2", "kafkajs": "^2.2.4", + "kysely": "^0.27.4", + "pg": "^8.12.0", "postgres": "^3.4.4", "redis": "^4.7.0", "ulid": "^2.3.0", diff --git a/server/prisma/migrations/20240824195639_init_db/migration.sql b/server/prisma/migrations/20240824195639_init_db/migration.sql deleted file mode 100644 index 79259042..00000000 --- a/server/prisma/migrations/20240824195639_init_db/migration.sql +++ /dev/null @@ -1,104 +0,0 @@ --- CreateTable -CREATE TABLE "accounts" ( - "id" VARCHAR(30) NOT NULL, - "name" VARCHAR(256) NOT NULL, - "email" VARCHAR(256) NOT NULL, - "avatar" VARCHAR(256), - "password" VARCHAR(256), - "attrs" JSONB, - "created_at" TIMESTAMPTZ(6) NOT NULL, - "updated_at" TIMESTAMPTZ(6), - "status" INTEGER NOT NULL, - - CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "workspaces" ( - "id" VARCHAR(30) NOT NULL, - "name" VARCHAR(256) NOT NULL, - "description" VARCHAR(256), - "avatar" VARCHAR(256), - "attrs" JSONB, - "created_at" TIMESTAMPTZ(6) NOT NULL, - "created_by" VARCHAR(30) NOT NULL, - "updated_at" TIMESTAMPTZ(6), - "updated_by" VARCHAR(30), - "status" INTEGER NOT NULL, - "version_id" VARCHAR(30) NOT NULL, - - CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "workspace_accounts" ( - "workspace_id" VARCHAR(30) NOT NULL, - "account_id" VARCHAR(30) NOT NULL, - "user_id" VARCHAR(30) NOT NULL, - "role" INTEGER NOT NULL, - "attrs" JSONB, - "created_at" TIMESTAMPTZ(6) NOT NULL, - "created_by" VARCHAR(30) NOT NULL, - "updated_at" TIMESTAMPTZ(6), - "updated_by" VARCHAR(30), - "status" INTEGER NOT NULL, - "version_id" VARCHAR(30) NOT NULL, - - CONSTRAINT "workspace_accounts_pkey" PRIMARY KEY ("workspace_id","account_id") -); - --- CreateTable -CREATE TABLE "nodes" ( - "id" VARCHAR(30) NOT NULL, - "workspace_id" VARCHAR(30) NOT NULL, - "parent_id" VARCHAR(30), - "type" VARCHAR(30) NOT NULL, - "index" VARCHAR(30), - "attrs" JSONB, - "content" JSONB, - "created_at" TIMESTAMPTZ(6) NOT NULL, - "created_by" VARCHAR(30) NOT NULL, - "updated_at" TIMESTAMPTZ(6), - "updated_by" VARCHAR(30), - "version_id" VARCHAR(30) NOT NULL, - "server_created_at" TIMESTAMPTZ(6) NOT NULL, - "server_updated_at" TIMESTAMPTZ(6), - "server_version_id" VARCHAR(30) NOT NULL, - - CONSTRAINT "nodes_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "account_devices" ( - "id" VARCHAR(30) NOT NULL, - "account_id" VARCHAR(30) NOT NULL, - "type" INTEGER NOT NULL, - "version" VARCHAR(30) NOT NULL, - "platform" VARCHAR(30), - "cpu" VARCHAR(30), - "hostname" VARCHAR(30), - "created_at" TIMESTAMPTZ(6) NOT NULL, - "last_online_at" TIMESTAMPTZ(6), - "last_active_at" TIMESTAMPTZ(6), - - CONSTRAINT "account_devices_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "mutations" ( - "id" VARCHAR(30) NOT NULL, - "type" VARCHAR(30) NOT NULL, - "workspace_id" VARCHAR(30) NOT NULL, - "data" JSONB, - "created_at" TIMESTAMPTZ(6) NOT NULL, - "device_id" VARCHAR(30), - "devices" TEXT[], - - CONSTRAINT "mutations_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "IX_accounts_email" ON "accounts"("email"); - --- AddForeignKey -ALTER TABLE "nodes" ADD CONSTRAINT "nodes_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml deleted file mode 100644 index fbffa92c..00000000 --- a/server/prisma/migrations/migration_lock.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma deleted file mode 100644 index 9ecfa115..00000000 --- a/server/prisma/schema.prisma +++ /dev/null @@ -1,105 +0,0 @@ -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model accounts { - id String @id @db.VarChar(30) - name String @db.VarChar(256) - email String @unique(map: "IX_accounts_email") @db.VarChar(256) - avatar String? @db.VarChar(256) - password String? @db.VarChar(256) - attrs Json? - createdAt DateTime @db.Timestamptz(6) @map("created_at") - updatedAt DateTime? @db.Timestamptz(6) @map("updated_at") - status Int - - @@map("accounts") -} - -model workspaces { - id String @id @db.VarChar(30) - name String @db.VarChar(256) - description String? @db.VarChar(256) - avatar String? @db.VarChar(256) - attrs Json? - createdAt DateTime @db.Timestamptz(6) @map("created_at") - createdBy String @db.VarChar(30) @map("created_by") - updatedAt DateTime? @db.Timestamptz(6) @map("updated_at") - updatedBy String? @db.VarChar(30) @map("updated_by") - status Int - versionId String @db.VarChar(30) @map("version_id") - - @@map("workspaces") -} - -model workspaceAccounts { - workspaceId String @db.VarChar(30) @map("workspace_id") - accountId String @db.VarChar(30) @map("account_id") - userId String @db.VarChar(30) @map("user_id") - role Int - attrs Json? - createdAt DateTime @db.Timestamptz(6) @map("created_at") - createdBy String @db.VarChar(30) @map("created_by") - updatedAt DateTime? @db.Timestamptz(6) @map("updated_at") - updatedBy String? @db.VarChar(30) @map("updated_by") - status Int - versionId String @db.VarChar(30) @map("version_id") - - @@map("workspace_accounts") - @@id([workspaceId, accountId]) -} - -model nodes { - id String @id @db.VarChar(30) - workspaceId String @db.VarChar(30) @map("workspace_id") - parentId String? @db.VarChar(30) @map("parent_id") - type String @db.VarChar(30) - index String? @db.VarChar(30) - attrs Json? - content Json? - createdAt DateTime @db.Timestamptz(6) @map("created_at") - createdBy String @db.VarChar(30) @map("created_by") - updatedAt DateTime? @db.Timestamptz(6) @map("updated_at") - updatedBy String? @db.VarChar(30) @map("updated_by") - versionId String @db.VarChar(30) @map("version_id") - serverCreatedAt DateTime @db.Timestamptz(6) @map("server_created_at") - serverUpdatedAt DateTime? @db.Timestamptz(6) @map("server_updated_at") - serverVersionId String @db.VarChar(30) @map("server_version_id") - - parent nodes? @relation("node_parent_child", fields: [parentId], references: [id], onDelete: Cascade) - children nodes[] @relation("node_parent_child") - - @@map("nodes") -} - -model accountDevices { - id String @id @db.VarChar(30) - accountId String @db.VarChar(30) @map("account_id") - type Int - version String @db.VarChar(30) @map("version") - platform String? @db.VarChar(30) - cpu String? @db.VarChar(30) - hostname String? @db.VarChar(30) - createdAt DateTime @db.Timestamptz(6) @map("created_at") - lastOnlineAt DateTime? @db.Timestamptz(6) @map("last_online_at") - lastActiveAt DateTime? @db.Timestamptz(6) @map("last_active_at") - - @@map("account_devices") -} - -model mutations { - id String @id @db.VarChar(30) - type String @db.VarChar(30) - workspaceId String @db.VarChar(30) @map("workspace_id") - data Json? - createdAt DateTime @db.Timestamptz(6) @map("created_at") - deviceId String? @db.VarChar(30) @map("device_id") - devices String[] - - @@map("mutations") -} diff --git a/server/src/api.ts b/server/src/api.ts index c0a1be11..b6ddab6e 100644 --- a/server/src/api.ts +++ b/server/src/api.ts @@ -9,8 +9,9 @@ import { mutationsRouter } from '@/routes/mutations'; import { authMiddleware } from '@/middlewares/auth'; import { sockets } from '@/lib/sockets'; import { SocketMessage } from '@/types/sockets'; -import { prisma } from '@/data/prisma'; import { syncRouter } from '@/routes/sync'; +import { database } from '@/data/database'; +import { sql } from 'kysely'; export const initApi = () => { const app = express(); @@ -53,11 +54,13 @@ export const initApi = () => { return; } - await prisma.$executeRaw` - UPDATE mutations - SET devices = array_remove(devices, ${deviceId}) - WHERE id = ${mutationId}; - `; + await database + .updateTable('mutations') + .set({ + device_ids: sql`array_remove(device_ids, ${deviceId})`, + }) + .where('id', '=', mutationId) + .execute(); } }); diff --git a/server/src/consumers/mutation-changes.ts b/server/src/consumers/mutation-changes.ts index 8fe22786..6c077ac1 100644 --- a/server/src/consumers/mutation-changes.ts +++ b/server/src/consumers/mutation-changes.ts @@ -2,7 +2,7 @@ import { kafka, TOPIC_NAMES, CONSUMER_IDS } from '@/data/kafka'; import { ChangeMessage, MutationChangeData } from '@/types/changes'; import { redis, CHANNEL_NAMES } from '@/data/redis'; import { PostgresOperation } from '@/lib/constants'; -import { prisma } from '@/data/prisma'; +import { database } from '@/data/database'; export const initMutationChangesConsumer = async () => { const consumer = kafka.consumer({ groupId: CONSUMER_IDS.MUTATION_CHANGES }); @@ -64,12 +64,11 @@ const handleMutationUpdate = async ( } // if all devices have acknowledged the mutation, delete it - if (mutationData.devices == null || mutationData.devices.length == 0) { - await prisma.mutations.delete({ - where: { - id: mutationData.id, - }, - }); + if (mutationData.device_ids == null || mutationData.device_ids.length == 0) { + await database + .deleteFrom('mutations') + .where('id', '=', mutationData.id) + .execute(); } }; diff --git a/server/src/consumers/mutations.ts b/server/src/consumers/mutations.ts index 1f2892cb..61a58a14 100644 --- a/server/src/consumers/mutations.ts +++ b/server/src/consumers/mutations.ts @@ -1,8 +1,8 @@ import { redis, CHANNEL_NAMES } from '@/data/redis'; import { sockets } from '@/lib/sockets'; import { SocketMessage } from '@/types/sockets'; -import { MutationChangeData } from '@/types/changes'; import { ServerMutation } from '@/types/mutations'; +import { MutationChangeData } from '@/types/changes'; export const initMutationsSubscriber = async () => { const subscriber = redis.duplicate(); @@ -12,27 +12,28 @@ export const initMutationsSubscriber = async () => { const handleMessage = async (message: string) => { const mutationData = JSON.parse(message) as MutationChangeData; - if (!mutationData.devices || !mutationData.devices.length) { + if (!mutationData.device_ids || !mutationData.device_ids.length) { return; } - const mutation: ServerMutation = { + const serverMutation: ServerMutation = { id: mutationData.id, - type: mutationData.type as any, + action: mutationData.action as 'insert' | 'update' | 'delete', + table: mutationData.table, workspaceId: mutationData.workspace_id, - data: JSON.parse(mutationData.data), - createdAt: mutationData.created_at, + before: mutationData.before, + after: mutationData.after, }; - for (const deviceId of mutationData.devices) { + for (const deviceId of mutationData.device_ids) { const socket = sockets.getSocket(deviceId); if (!socket) { - return; + continue; } const socketMessage: SocketMessage = { type: 'mutation', - payload: mutation, + payload: serverMutation, }; socket.send(JSON.stringify(socketMessage)); } diff --git a/server/src/consumers/node-changes.ts b/server/src/consumers/node-changes.ts new file mode 100644 index 00000000..afe55184 --- /dev/null +++ b/server/src/consumers/node-changes.ts @@ -0,0 +1,161 @@ +import { kafka, TOPIC_NAMES, CONSUMER_IDS } from '@/data/kafka'; +import { ChangeMessage, NodeChangeData } from '@/types/changes'; +import { PostgresOperation } from '@/lib/constants'; +import { database } from '@/data/database'; +import { NeuronId } from '@/lib/id'; +import { ServerNode } from '@/types/nodes'; + +export const initNodeChangesConsumer = async () => { + const consumer = kafka.consumer({ groupId: CONSUMER_IDS.NODE_CHANGES }); + + await consumer.connect(); + await consumer.subscribe({ topic: TOPIC_NAMES.NODE_CHANGES }); + + await consumer.run({ + eachMessage: async ({ message }) => { + if (!message || !message.value) { + return; + } + + const change = JSON.parse( + message.value.toString(), + ) as ChangeMessage; + + await handleNodeChange(change); + }, + }); +}; + +const handleNodeChange = async (change: ChangeMessage) => { + switch (change.op) { + case PostgresOperation.CREATE: { + await handleNodeCreate(change); + break; + } + case PostgresOperation.UPDATE: { + await handleNodeUpdate(change); + break; + } + case PostgresOperation.DELETE: { + await handleNodeDelete(change); + break; + } + } +}; + +const handleNodeCreate = async (change: ChangeMessage) => { + const node = change.after; + if (!node) { + return; + } + + const deviceIds = await getDeviceIds(node.workspace_id); + if (deviceIds.length == 0) { + return; + } + + const serverNode: ServerNode = mapNode(node); + await database + .insertInto('mutations') + .values({ + id: NeuronId.generate(NeuronId.Type.Mutation), + table: 'nodes', + action: 'insert', + workspace_id: node.workspace_id, + created_at: new Date(), + after: JSON.stringify(serverNode), + device_ids: deviceIds, + }) + .execute(); +}; + +const handleNodeUpdate = async (change: ChangeMessage) => { + const node = change.after; + if (!node) { + return; + } + + const deviceIds = await getDeviceIds(node.workspace_id); + if (deviceIds.length == 0) { + return; + } + + const serverNode: ServerNode = mapNode(node); + await database + .insertInto('mutations') + .values({ + id: NeuronId.generate(NeuronId.Type.Mutation), + table: 'nodes', + action: 'update', + workspace_id: node.workspace_id, + created_at: new Date(), + before: change.before ? JSON.stringify(change.before) : null, + after: JSON.stringify(serverNode), + device_ids: deviceIds, + }) + .execute(); +}; + +const handleNodeDelete = async (change: ChangeMessage) => { + const node = change.before; + if (!node) { + return; + } + + const deviceIds = await getDeviceIds(node.workspace_id); + if (deviceIds.length == 0) { + return; + } + + const serverNode: ServerNode = mapNode(node); + await database + .insertInto('mutations') + .values({ + id: NeuronId.generate(NeuronId.Type.Mutation), + table: 'nodes', + action: 'delete', + workspace_id: node.workspace_id, + created_at: new Date(), + before: JSON.stringify(serverNode), + after: null, + device_ids: deviceIds, + }) + .execute(); +}; + +const getDeviceIds = async (workspaceId: string) => { + const accountDevices = await database + .selectFrom('account_devices') + .where( + 'account_id', + 'in', + database + .selectFrom('workspace_accounts') + .where('workspace_id', '=', workspaceId) + .select('account_id'), + ) + .select('id') + .execute(); + + const deviceIds = accountDevices.map((account) => account.id); + return deviceIds; +}; + +const mapNode = (node: NodeChangeData): ServerNode => { + return { + id: node.id, + workspaceId: node.workspace_id, + parentId: node.parent_id, + type: node.type, + index: node.index, + attrs: node.attrs ? JSON.parse(node.attrs) : null, + content: node.content ? JSON.parse(node.content) : null, + createdAt: node.created_at, + createdBy: node.created_by, + updatedAt: node.updated_at, + updatedBy: node.updated_by, + versionId: node.version_id, + serverCreatedAt: node.server_created_at, + serverUpdatedAt: node.server_updated_at, + }; +}; diff --git a/server/src/data/database.ts b/server/src/data/database.ts new file mode 100644 index 00000000..d4ae0618 --- /dev/null +++ b/server/src/data/database.ts @@ -0,0 +1,27 @@ +import { Kysely, Migration, Migrator, PostgresDialect } from 'kysely'; +import { Pool } from 'pg'; +import { DatabaseSchema } from '@/data/schema'; +import { databaseMigrations } from '@/data/migrations'; + +const dialect = new PostgresDialect({ + pool: new Pool({ + connectionString: process.env.DATABASE_URL, + }), +}); + +export const database = new Kysely({ + dialect, +}); + +export const migrate = async () => { + const migrator = new Migrator({ + db: database, + provider: { + getMigrations(): Promise> { + return Promise.resolve(databaseMigrations); + }, + }, + }); + + await migrator.migrateToLatest(); +}; diff --git a/server/src/data/migrations.ts b/server/src/data/migrations.ts new file mode 100644 index 00000000..ca165e0f --- /dev/null +++ b/server/src/data/migrations.ts @@ -0,0 +1,150 @@ +import { Migration, sql } from 'kysely'; + +const createAccountsTable: Migration = { + up: async (db) => { + await db.schema + .createTable('accounts') + .addColumn('id', 'varchar(30)', (col) => col.notNull().primaryKey()) + .addColumn('name', 'varchar(256)', (col) => col.notNull()) + .addColumn('email', 'varchar(256)', (col) => col.notNull().unique()) + .addColumn('avatar', 'varchar(256)') + .addColumn('password', 'varchar(256)') + .addColumn('attrs', 'jsonb') + .addColumn('created_at', 'timestamptz', (col) => col.notNull()) + .addColumn('updated_at', 'timestamptz') + .addColumn('status', 'integer', (col) => col.notNull()) + .execute(); + }, + down: async (db) => { + await db.schema.dropTable('accounts').execute(); + }, +}; + +const createWorkspacesTable: Migration = { + up: async (db) => { + await db.schema + .createTable('workspaces') + .addColumn('id', 'varchar(30)', (col) => col.notNull().primaryKey()) + .addColumn('name', 'varchar(256)', (col) => col.notNull()) + .addColumn('description', 'varchar(256)') + .addColumn('avatar', 'varchar(256)') + .addColumn('attrs', 'jsonb') + .addColumn('created_at', 'timestamptz', (col) => col.notNull()) + .addColumn('created_by', 'varchar(30)', (col) => col.notNull()) + .addColumn('updated_at', 'timestamptz') + .addColumn('updated_by', 'varchar(30)') + .addColumn('status', 'integer', (col) => col.notNull()) + .addColumn('version_id', 'varchar(30)', (col) => col.notNull()) + .execute(); + }, + down: async (db) => { + await db.schema.dropTable('workspaces').execute(); + }, +}; + +const createWorkspaceAccountsTable: Migration = { + up: async (db) => { + await db.schema + .createTable('workspace_accounts') + .addColumn('workspace_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('account_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('user_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('role', 'integer', (col) => col.notNull()) + .addColumn('attrs', 'jsonb') + .addColumn('created_at', 'timestamptz', (col) => col.notNull()) + .addColumn('created_by', 'varchar(30)', (col) => col.notNull()) + .addColumn('updated_at', 'timestamptz') + .addColumn('updated_by', 'varchar(30)') + .addColumn('status', 'integer', (col) => col.notNull()) + .addColumn('version_id', 'varchar(30)', (col) => col.notNull()) + .addPrimaryKeyConstraint('workspace_accounts_pkey', [ + 'workspace_id', + 'account_id', + ]) + .execute(); + }, + down: async (db) => { + await db.schema.dropTable('workspace_accounts').execute(); + }, +}; + +const createNodesTable: Migration = { + up: async (db) => { + await db.schema + .createTable('nodes') + .addColumn('id', 'varchar(30)', (col) => col.notNull().primaryKey()) + .addColumn('workspace_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('parent_id', 'varchar(30)') + .addColumn('type', 'varchar(30)', (col) => col.notNull()) + .addColumn('index', 'varchar(30)') + .addColumn('attrs', 'jsonb') + .addColumn('content', 'jsonb') + .addColumn('created_at', 'timestamptz', (col) => col.notNull()) + .addColumn('created_by', 'varchar(30)', (col) => col.notNull()) + .addColumn('updated_at', 'timestamptz') + .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') + .addForeignKeyConstraint( + 'nodes_parent_id_fkey', + ['parent_id'], + 'nodes', + ['id'], + (cb) => cb.onDelete('cascade'), + ) + .execute(); + }, + down: async (db) => { + await db.schema.dropTable('nodes').execute(); + }, +}; + +const createAccountDevicesTable: Migration = { + up: async (db) => { + await db.schema + .createTable('account_devices') + .addColumn('id', 'varchar(30)', (col) => col.notNull().primaryKey()) + .addColumn('account_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('type', 'integer', (col) => col.notNull()) + .addColumn('version', 'varchar(30)', (col) => col.notNull()) + .addColumn('platform', 'varchar(30)') + .addColumn('cpu', 'varchar(30)') + .addColumn('hostname', 'varchar(30)') + .addColumn('created_at', 'timestamptz', (col) => col.notNull()) + .addColumn('last_online_at', 'timestamptz') + .addColumn('last_active_at', 'timestamptz') + .execute(); + }, + down: async (db) => { + await db.schema.dropTable('account_devices').execute(); + }, +}; + +const createMutationsTable: Migration = { + up: async (db) => { + await db.schema + .createTable('mutations') + .addColumn('id', 'varchar(30)', (col) => col.notNull().primaryKey()) + .addColumn('workspace_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('table', 'varchar(30)', (col) => col.notNull()) + .addColumn('action', 'varchar(30)', (col) => col.notNull()) + .addColumn('after', 'jsonb') + .addColumn('before', 'jsonb') + .addColumn('created_at', 'timestamptz', (col) => col.notNull()) + .addColumn('device_ids', sql`text[]`, (col) => col.notNull()) + .execute(); + }, + down: async (db) => { + await db.schema.dropTable('mutations').execute(); + }, +}; + +export const databaseMigrations: Record = { + '202408011709_create_accounts_table': createAccountsTable, + '202408011712_create_workspaces_table': createWorkspacesTable, + '202408011715_create_workspace_accounts_table': createWorkspaceAccountsTable, + '202408011718_create_nodes_table': createNodesTable, + '202408011721_create_account_devices_table': createAccountDevicesTable, + '202408011724_create_mutations_table': createMutationsTable, +}; diff --git a/server/src/data/prisma.ts b/server/src/data/prisma.ts deleted file mode 100644 index 9b6c4ce3..00000000 --- a/server/src/data/prisma.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { PrismaClient } from '@prisma/client'; - -export const prisma = new PrismaClient(); diff --git a/server/src/data/schema.ts b/server/src/data/schema.ts new file mode 100644 index 00000000..9a8811c1 --- /dev/null +++ b/server/src/data/schema.ts @@ -0,0 +1,126 @@ +import { NodeBlock } from '@/types/nodes'; +import { + ColumnType, + Insertable, + JSONColumnType, + Selectable, + Updateable, +} from 'kysely'; + +interface AccountTable { + id: ColumnType; + name: ColumnType; + email: ColumnType; + avatar: ColumnType; + password: ColumnType; + attrs: ColumnType; + created_at: ColumnType; + updated_at: ColumnType; + status: ColumnType; +} + +export type SelectAccount = Selectable; +export type CreateAccount = Insertable; +export type UpdateAccount = Updateable; + +interface WorkspaceTable { + id: ColumnType; + name: ColumnType; + description: ColumnType; + avatar: ColumnType; + attrs: ColumnType; + created_at: ColumnType; + updated_at: ColumnType; + created_by: ColumnType; + updated_by: ColumnType; + status: ColumnType; + version_id: ColumnType; +} + +export type SelectWorkspace = Selectable; +export type CreateWorkspace = Insertable; +export type UpdateWorkspace = Updateable; + +interface WorkspaceAccountTable { + workspace_id: ColumnType; + account_id: ColumnType; + user_id: ColumnType; + role: ColumnType; + attrs: ColumnType; + created_at: ColumnType; + created_by: ColumnType; + updated_at: ColumnType; + updated_by: ColumnType; + status: ColumnType; + version_id: ColumnType; +} + +export type SelectWorkspaceAccount = Selectable; +export type CreateWorkspaceAccount = Insertable; +export type UpdateWorkspaceAccount = Updateable; + +interface AccountDeviceTable { + id: ColumnType; + account_id: ColumnType; + type: ColumnType; + version: ColumnType; + platform: ColumnType; + cpu: ColumnType; + hostname: ColumnType; + created_at: ColumnType; + last_online_at: ColumnType; + last_active_at: ColumnType; +} + +export type SelectAccountDevice = Selectable; +export type CreateAccountDevice = Insertable; +export type UpdateAccountDevice = Updateable; + +interface NodeTable { + id: ColumnType; + workspace_id: ColumnType; + parent_id: ColumnType; + type: ColumnType; + index: ColumnType; + attrs: JSONColumnType< + Record | null, + string | null, + string | null + >; + content: JSONColumnType; + created_at: ColumnType; + updated_at: ColumnType; + created_by: ColumnType; + updated_by: ColumnType; + version_id: ColumnType; + server_created_at: ColumnType; + server_updated_at: ColumnType; +} + +export type SelectNode = Selectable; +export type CreateNode = Insertable; +export type UpdateNode = Updateable; + +interface MutationTable { + id: ColumnType; + workspace_id: ColumnType; + table: ColumnType; + action: ColumnType; + after: ColumnType; + before: ColumnType; + created_at: ColumnType; + device_ids: ColumnType; +} + +export type SelectMutation = Selectable; +export type CreateMutation = Insertable; +export type UpdateMutation = Updateable; + +export interface DatabaseSchema { + accounts: AccountTable; + workspaces: WorkspaceTable; + workspace_accounts: WorkspaceAccountTable; + account_devices: AccountDeviceTable; + nodes: NodeTable; + mutations: MutationTable; +} diff --git a/server/src/index.ts b/server/src/index.ts index c882cbc6..06cdeb52 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,17 +1,25 @@ import { initApi } from '@/api'; import { initRedis } from '@/data/redis'; +import { initNodeChangesConsumer } from '@/consumers/node-changes'; import { initMutationChangesConsumer } from '@/consumers/mutation-changes'; import { initMutationsSubscriber } from '@/consumers/mutations'; +import { migrate } from './data/database'; -initApi(); +migrate().then(() => { + initApi(); -initMutationChangesConsumer().then(() => { - console.log('Mutation changes consumer started'); -}); + initNodeChangesConsumer().then(() => { + console.log('Node changes consumer started'); + }); -initRedis().then(() => { - console.log('Redis initialized'); - initMutationsSubscriber().then(() => { - console.log('Mutation subscriber started'); + initMutationChangesConsumer().then(() => { + console.log('Mutation changes consumer started'); + }); + + initRedis().then(() => { + console.log('Redis initialized'); + initMutationsSubscriber().then(() => { + console.log('Mutation subscriber started'); + }); }); }); diff --git a/server/src/routes/accounts.ts b/server/src/routes/accounts.ts index 6681a393..085e9f79 100644 --- a/server/src/routes/accounts.ts +++ b/server/src/routes/accounts.ts @@ -10,7 +10,7 @@ import { import axios from 'axios'; import { ApiError } from '@/types/api'; import { NeuronId } from '@/lib/id'; -import { prisma } from '@/data/prisma'; +import { database } from '@/data/database'; import bcrypt from 'bcrypt'; import { WorkspaceOutput } from '@/types/workspaces'; import { createJwtToken } from '@/lib/jwt'; @@ -22,11 +22,10 @@ export const accountsRouter = Router(); accountsRouter.post('/register/email', async (req: Request, res: Response) => { const input: EmailRegisterInput = req.body; - let existingAccount = await prisma.accounts.findUnique({ - where: { - email: input.email, - }, - }); + let existingAccount = await database + .selectFrom('accounts') + .where('email', '=', input.email) + .executeTakeFirst(); if (existingAccount) { return res.status(400).json({ @@ -37,16 +36,25 @@ accountsRouter.post('/register/email', async (req: Request, res: Response) => { const salt = await bcrypt.genSalt(SaltRounds); const password = await bcrypt.hash(input.password, salt); - const account = await prisma.accounts.create({ - data: { + const account = await database + .insertInto('accounts') + .values({ id: NeuronId.generate(NeuronId.Type.Account), name: input.name, email: input.email, password: password, status: AccountStatus.Active, - createdAt: new Date(), - }, - }); + created_at: new Date(), + }) + .returningAll() + .executeTakeFirst(); + + if (!account) { + return res.status(500).json({ + code: ApiError.InternalServerError, + message: 'Failed to create account.', + }); + } const output = await buildLoginOutput( account.id, @@ -59,11 +67,11 @@ accountsRouter.post('/register/email', async (req: Request, res: Response) => { accountsRouter.post('/login/email', async (req: Request, res: Response) => { const input: EmailLoginInput = req.body; - let account = await prisma.accounts.findUnique({ - where: { - email: input.email, - }, - }); + let account = await database + .selectFrom('accounts') + .where('email', '=', input.email) + .selectAll() + .executeTakeFirst(); if (!account) { return res.status(400).json({ @@ -123,11 +131,11 @@ accountsRouter.post('/login/google', async (req: Request, res: Response) => { }); } - let existingAccount = await prisma.accounts.findUnique({ - where: { - email: googleUser.email, - }, - }); + let existingAccount = await database + .selectFrom('accounts') + .where('email', '=', googleUser.email) + .selectAll() + .executeTakeFirst(); if (existingAccount) { if (existingAccount.status === AccountStatus.Pending) { @@ -137,17 +145,18 @@ accountsRouter.post('/login/google', async (req: Request, res: Response) => { }); } - const attrs = (existingAccount.attrs as Record) ?? {}; + const attrs = existingAccount.attrs + ? JSON.parse(existingAccount.attrs) + : {}; + if (attrs?.googleId) { - await prisma.accounts.update({ - where: { - id: existingAccount.id, - }, - data: { - attrs: { googleId: googleUser.id }, - updatedAt: new Date(), - }, - }); + await database + .updateTable('accounts') + .set({ + attrs: JSON.stringify({ googleId: googleUser.id }), + updated_at: new Date(), + }) + .execute(); } const output = await buildLoginOutput( @@ -158,15 +167,25 @@ accountsRouter.post('/login/google', async (req: Request, res: Response) => { return res.status(200).json(output); } - const newAccount = await prisma.accounts.create({ - data: { + const newAccount = await database + .insertInto('accounts') + .values({ id: NeuronId.generate(NeuronId.Type.Account), name: googleUser.name, email: googleUser.email, status: AccountStatus.Active, - createdAt: new Date(), - }, - }); + created_at: new Date(), + password: null, + }) + .returningAll() + .executeTakeFirst(); + + if (!newAccount) { + return res.status(500).json({ + code: ApiError.InternalServerError, + message: 'Failed to create account.', + }); + } const output = await buildLoginOutput( newAccount.id, @@ -187,51 +206,57 @@ const buildLoginOutput = async ( email, }); - const accountWorkspaces = await prisma.workspaceAccounts.findMany({ - where: { - accountId: id, - }, - }); - - const workspaceIds = accountWorkspaces.map((wa) => wa.workspaceId); - const workspaces = await prisma.workspaces.findMany({ - where: { - id: { - in: workspaceIds, - }, - }, - }); + const accountWorkspaces = await database + .selectFrom('workspace_accounts') + .where('account_id', '=', id) + .selectAll() + .execute(); const workspaceOutputs: WorkspaceOutput[] = []; - for (const accountWorkspace of accountWorkspaces) { - const workspace = workspaces.find( - (w) => w.id === accountWorkspace.workspaceId, - ); - if (!workspace) { - continue; - } + const workspaceIds = accountWorkspaces.map((wa) => wa.workspace_id); + if (workspaceIds.length > 0) { + const workspaces = await database + .selectFrom('workspaces') + .where('id', 'in', workspaceIds) + .selectAll() + .execute(); - workspaceOutputs.push({ - id: workspace.id, - name: workspace.name, - role: accountWorkspace.role, - userId: accountWorkspace.userId, - versionId: accountWorkspace.versionId, - accountId: accountWorkspace.accountId, - avatar: workspace.avatar, - description: workspace.description, - }); + for (const accountWorkspace of accountWorkspaces) { + const workspace = workspaces.find( + (w) => w.id === accountWorkspace.workspace_id, + ); + if (!workspace) { + continue; + } + + workspaceOutputs.push({ + id: workspace.id, + name: workspace.name, + role: accountWorkspace.role, + userId: accountWorkspace.user_id, + versionId: accountWorkspace.version_id, + accountId: accountWorkspace.account_id, + avatar: workspace.avatar, + description: workspace.description, + }); + } } - const accountDevice = await prisma.accountDevices.create({ - data: { + const accountDevice = await database + .insertInto('account_devices') + .values({ id: NeuronId.generate(NeuronId.Type.Device), - accountId: id, + account_id: id, type: 1, - createdAt: new Date(), + created_at: new Date(), version: '0.1.0', - }, - }); + }) + .returningAll() + .executeTakeFirst(); + + if (!accountDevice) { + throw new Error('Failed to create account device.'); + } return { account: { diff --git a/server/src/routes/mutations.ts b/server/src/routes/mutations.ts index ada05e91..31994ef0 100644 --- a/server/src/routes/mutations.ts +++ b/server/src/routes/mutations.ts @@ -1,575 +1,133 @@ import { NeuronRequest, NeuronResponse } from '@/types/api'; import { Router } from 'express'; import { + ExecuteLocalMutationsInput, LocalMutation, - LocalCreateNodeMutation, - LocalCreateNodesMutation, - LocalUpdateNodeMutation, - LocalDeleteNodeMutation, - LocalDeleteNodesMutation, - LocalCreateNodeData, - ServerMutation, - ServerCreateNodeMutation, - ServerCreateNodesMutation, - ServerUpdateNodeMutation, - ServerDeleteNodeMutation, - ServerDeleteNodesMutation, + LocalNodeMutationData, } from '@/types/mutations'; -import { prisma } from '@/data/prisma'; -import { Node, NodeBlock } from '@/types/nodes'; -import { Prisma } from '@prisma/client'; -import { NeuronId } from '@/lib/id'; +import { database } from '@/data/database'; export const mutationsRouter = Router(); mutationsRouter.post('/', async (req: NeuronRequest, res: NeuronResponse) => { - const localMutation = req.body as LocalMutation; - - switch (localMutation.type) { - case 'create_node': { - await handleCreateNodeMutation(req, res, localMutation); - break; - } - case 'create_nodes': { - await handleCreateNodesMutation(req, res, localMutation); - break; - } - case 'update_node': { - await handleUpdateNodeMutation(req, res, localMutation); - break; - } - case 'delete_node': { - await handleDeleteNodeMutation(req, res, localMutation); - break; - } - case 'delete_nodes': { - await handleDeleteNodesMutation(req, res, localMutation); - break; + const input = req.body as ExecuteLocalMutationsInput; + for (const mutation of input.mutations) { + switch (mutation.table) { + case 'nodes': { + switch (mutation.action) { + case 'insert': { + await handleCreateNodeMutation(input.workspaceId, mutation); + break; + } + case 'update': { + await handleUpdateNodeMutation(input.workspaceId, mutation); + break; + } + case 'delete': { + await handleDeleteNodeMutation(input.workspaceId, mutation); + break; + } + } + } } } + + res.status(200).json({ success: true }); }); const handleCreateNodeMutation = async ( - req: NeuronRequest, - res: NeuronResponse, - localMutation: LocalCreateNodeMutation, -): Promise => { - if (!req.accountId) { - return res.status(401).json({ success: false }); + workspaceId: string, + mutation: LocalMutation, +): Promise => { + if (!mutation.after) { + return; } - const existingNode = await prisma.nodes.findUnique({ - where: { - id: localMutation.data.node.id, - }, - }); + const nodeData = JSON.parse(mutation.after) as LocalNodeMutationData; + const existingNode = await database + .selectFrom('nodes') + .where('id', '=', nodeData.id) + .executeTakeFirst(); if (existingNode) { - const serverMutation: ServerCreateNodeMutation = { - id: NeuronId.generate(NeuronId.Type.Mutation), - type: 'create_node', - workspaceId: localMutation.workspaceId, - createdAt: new Date().toISOString(), - data: { - node: { - id: existingNode.id, - parentId: existingNode.parentId, - workspaceId: existingNode.workspaceId, - type: existingNode.type, - index: existingNode.index, - attrs: existingNode.attrs as Record, - content: existingNode.content as NodeBlock[], - createdAt: existingNode.createdAt?.toISOString(), - createdBy: existingNode.createdBy, - updatedAt: existingNode.updatedAt?.toISOString(), - updatedBy: existingNode.updatedBy, - versionId: existingNode.versionId, - serverCreatedAt: existingNode.serverCreatedAt?.toISOString(), - serverUpdatedAt: existingNode.serverUpdatedAt?.toISOString(), - serverVersionId: existingNode.serverVersionId, - }, - }, - }; - return res.status(200).json(serverMutation); + return; } - const node = buildNode(localMutation.data.node); - const workspaceId = node.workspaceId; - const workspaceAccounts = await prisma.workspaceAccounts.findMany({ - where: { - workspaceId, - }, - }); - - if (workspaceAccounts.length === 0) { - return res.status(401).json({ success: false }); - } - - const accountIds = workspaceAccounts.map((account) => account.accountId); - if (!accountIds.includes(req.accountId)) { - return res.status(401).json({ success: false }); - } - - const accountDevices = await prisma.accountDevices.findMany({ - where: { - accountId: { - in: accountIds, - }, - }, - }); - const deviceIds = accountDevices.map((device) => device.id); - - const result = await prisma.$transaction(async (tx) => { - const row = await tx.nodes.create({ - data: { - ...node, - attrs: node.attrs === null ? Prisma.DbNull : node.attrs, - content: node.content === null ? Prisma.DbNull : node.content, - }, - }); - - if (!row) { - throw new Error('Failed to create node'); - } - - const serverMutation: ServerCreateNodeMutation = { - id: NeuronId.generate(NeuronId.Type.Mutation), - workspaceId: localMutation.workspaceId, - type: 'create_node', - createdAt: new Date().toISOString(), - data: { - node: node, - }, - }; - - await prisma.mutations.create({ - data: { - ...serverMutation, - devices: deviceIds, - deviceId: localMutation.deviceId, - }, - }); - - return serverMutation; - }); - - return res.status(200).json(result); -}; - -const handleCreateNodesMutation = async ( - req: NeuronRequest, - res: NeuronResponse, - localMutation: LocalCreateNodesMutation, -): Promise => { - if (!req.accountId) { - return res.status(401).json({ success: false }); - } - - const existingNodes = await prisma.nodes.findMany({ - where: { - id: { - in: localMutation.data.nodes.map((node) => node.id), - }, - }, - }); - - const existingNodeIds = existingNodes.map((node) => node.id); - if (existingNodeIds.length === localMutation.data.nodes.length) { - const serverMutation: ServerCreateNodesMutation = { - id: NeuronId.generate(NeuronId.Type.Mutation), - type: 'create_nodes', - workspaceId: localMutation.workspaceId, - createdAt: new Date().toISOString(), - data: { - nodes: existingNodes.map((node) => ({ - id: node.id, - parentId: node.parentId, - workspaceId: node.workspaceId, - type: node.type, - index: node.index, - attrs: node.attrs as Record, - content: node.content as NodeBlock[], - createdAt: node.createdAt?.toISOString(), - createdBy: node.createdBy, - updatedAt: node.updatedAt?.toISOString(), - updatedBy: node.updatedBy, - versionId: node.versionId, - serverCreatedAt: node.serverCreatedAt?.toISOString(), - serverUpdatedAt: node.serverUpdatedAt?.toISOString(), - serverVersionId: node.serverVersionId, - })), - }, - }; - return res.status(200).json(serverMutation); - } - - const nodes = localMutation.data.nodes.map((node) => buildNode(node)); - - const nodesToInsert = nodes.filter( - (node) => !existingNodeIds.includes(node.id), - ); - - if (nodes.length === 0) { - return res.status(400).json({ success: false }); - } - - const workspaceId = nodes[0].workspaceId; - const workspaceAccounts = await prisma.workspaceAccounts.findMany({ - where: { - workspaceId, - }, - }); - - if (workspaceAccounts.length === 0) { - return res.status(401).json({ success: false }); - } - - const accountIds = workspaceAccounts.map((account) => account.accountId); - if (!accountIds.includes(req.accountId)) { - return res.status(401).json({ success: false }); - } - - const accountDevices = await prisma.accountDevices.findMany({ - where: { - accountId: { - in: accountIds, - }, - }, - }); - const deviceIds = accountDevices.map((device) => device.id); - - const result = await prisma.$transaction(async (tx) => { - const payload = await tx.nodes.createMany({ - data: nodesToInsert.map((node) => ({ - ...node, - attrs: node.attrs === null ? Prisma.DbNull : node.attrs, - content: node.content === null ? Prisma.DbNull : node.content, - })), - }); - - if (payload.count !== nodes.length) { - throw new Error('Failed to create nodes'); - } - - const serverMutation: ServerCreateNodesMutation = { - id: NeuronId.generate(NeuronId.Type.Mutation), - type: 'create_nodes', - workspaceId: workspaceId, - createdAt: new Date().toISOString(), - data: { - nodes, - }, - }; - - await tx.mutations.create({ - data: { - ...serverMutation, - devices: deviceIds, - deviceId: localMutation.deviceId, - }, - }); - - return serverMutation; - }); - - return res.status(200).json(result); + await database + .insertInto('nodes') + .values({ + id: nodeData.id, + parent_id: nodeData.parent_id, + workspace_id: workspaceId, + type: nodeData.type, + index: nodeData.index, + attrs: nodeData.attrs, + content: nodeData.content, + created_at: new Date(nodeData.created_at), + created_by: nodeData.created_by, + version_id: nodeData.version_id, + server_created_at: new Date(), + }) + .execute(); }; const handleUpdateNodeMutation = async ( - req: NeuronRequest, - res: NeuronResponse, - localMutation: LocalUpdateNodeMutation, -): Promise => { - if (!req.accountId) { - return res.status(401).json({ success: false }); + workspaceId: string, + mutation: LocalMutation, +): Promise => { + if (!mutation.after) { + return; } - const id = localMutation.data.id; - const existingNode = await prisma.nodes.findUnique({ - where: { - id, - }, - }); + const nodeData = JSON.parse(mutation.after) as LocalNodeMutationData; + const existingNode = await database + .selectFrom('nodes') + .where('id', '=', nodeData.id) + .executeTakeFirst(); if (!existingNode) { - return res.status(404).json({ success: false }); + return; } - const workspaceId = existingNode.workspaceId; - const workspaceAccounts = await prisma.workspaceAccounts.findMany({ - where: { - workspaceId, - }, - }); + const updatedAt = nodeData.updated_at + ? new Date(nodeData.updated_at) + : new Date(); + const updatedBy = nodeData.updated_by ?? nodeData.created_by; - if (workspaceAccounts.length === 0) { - return res.status(401).json({ success: false }); - } - - const accountIds = workspaceAccounts.map((account) => account.accountId); - if (!accountIds.includes(req.accountId)) { - return res.status(401).json({ success: false }); - } - - const accountDevices = await prisma.accountDevices.findMany({ - where: { - accountId: { - in: accountIds, - }, - }, - }); - const deviceIds = accountDevices.map((device) => device.id); - - const data = localMutation.data; - const result = await prisma.$transaction(async (tx) => { - const row = await tx.nodes.update({ - where: { - id, - }, - data: { - type: data.type, - parentId: data.parentId, - index: data.index, - content: data.content ? data.content : Prisma.DbNull, - attrs: data.attrs ? data.attrs : Prisma.DbNull, - updatedAt: data.updatedAt, - updatedBy: data.updatedBy, - versionId: data.versionId, - serverUpdatedAt: new Date().toISOString(), - serverVersionId: data.versionId, - }, - }); - - if (!row) { - throw new Error('Failed to update node'); - } - - const node: Node = { - id: row.id, - parentId: row.parentId, - workspaceId: row.workspaceId, - type: row.type, - index: row.index, - attrs: row.attrs as Record, - content: row.content as NodeBlock[], - createdAt: row.createdAt?.toISOString(), - createdBy: row.createdBy, - updatedAt: row.updatedAt?.toISOString(), - updatedBy: row.updatedBy, - versionId: row.versionId, - serverCreatedAt: row.serverCreatedAt?.toISOString(), - serverUpdatedAt: row.serverUpdatedAt?.toISOString(), - serverVersionId: row.serverVersionId, - }; - - const serverMutation: ServerUpdateNodeMutation = { - id: NeuronId.generate(NeuronId.Type.Mutation), - type: 'update_node', - workspaceId: workspaceId, - createdAt: new Date().toISOString(), - data: { - node: node, - }, - }; - - await tx.mutations.create({ - data: { - ...serverMutation, - devices: deviceIds, - deviceId: localMutation.deviceId, - }, - }); - - return serverMutation; - }); - - return res.status(200).json(result); + await database + .updateTable('nodes') + .set({ + parent_id: nodeData.parent_id, + type: nodeData.type, + index: nodeData.index, + attrs: nodeData.attrs, + content: nodeData.content, + updated_at: updatedAt, + updated_by: updatedBy, + version_id: nodeData.version_id, + server_updated_at: new Date(), + }) + .execute(); }; const handleDeleteNodeMutation = async ( - req: NeuronRequest, - res: NeuronResponse, - localMutation: LocalDeleteNodeMutation, -): Promise => { - if (!req.accountId) { - return res.status(401).json({ success: false }); + workspaceId: string, + mutation: LocalMutation, +): Promise => { + if (!mutation.before) { + return; } - const id = localMutation.data.id; - const existingNode = await prisma.nodes.findUnique({ - where: { - id, - }, - }); + const nodeData = JSON.parse(mutation.before) as LocalNodeMutationData; + const existingNode = await database + .selectFrom('nodes') + .where('id', '=', nodeData.id) + .select(['id', 'workspace_id']) + .executeTakeFirst(); - if (!existingNode) { - const serverMutation: ServerMutation = { - id: NeuronId.generate(NeuronId.Type.Mutation), - type: 'delete_node', - workspaceId: localMutation.workspaceId, - createdAt: new Date().toISOString(), - data: { - id, - }, - }; - return res.status(200).json(serverMutation); + if (!existingNode || existingNode.workspace_id !== workspaceId) { + return; } - const workspaceId = existingNode.workspaceId; - const workspaceAccounts = await prisma.workspaceAccounts.findMany({ - where: { - workspaceId, - }, - }); - - if (workspaceAccounts.length === 0) { - return res.status(401).json({ success: false }); - } - - const accountIds = workspaceAccounts.map((account) => account.accountId); - if (!accountIds.includes(req.accountId)) { - return res.status(401).json({ success: false }); - } - - const accountDevices = await prisma.accountDevices.findMany({ - where: { - accountId: { - in: accountIds, - }, - }, - }); - const deviceIds = accountDevices.map((device) => device.id); - - const result = await prisma.$transaction(async (tx) => { - await tx.nodes.delete({ - where: { - id, - }, - }); - - const serverMutation: ServerDeleteNodeMutation = { - id: NeuronId.generate(NeuronId.Type.Mutation), - type: 'delete_node', - workspaceId: workspaceId, - createdAt: new Date().toISOString(), - data: { - id, - }, - }; - - await tx.mutations.create({ - data: { - ...serverMutation, - devices: deviceIds, - deviceId: localMutation.deviceId, - }, - }); - }); - - return res.status(200).json(result); -}; - -const handleDeleteNodesMutation = async ( - req: NeuronRequest, - res: NeuronResponse, - localMutation: LocalDeleteNodesMutation, -): Promise => { - if (!req.accountId) { - return res.status(401).json({ success: false }); - } - - const ids = localMutation.data.ids; - const existingNodes = await prisma.nodes.findMany({ - where: { - id: { - in: ids, - }, - }, - }); - - const nodesToDelete = existingNodes.map((node) => node.id); - if (nodesToDelete.length === 0) { - const serverMutation: ServerDeleteNodesMutation = { - id: NeuronId.generate(NeuronId.Type.Mutation), - type: 'delete_nodes', - workspaceId: localMutation.workspaceId, - createdAt: new Date().toISOString(), - data: { - ids, - }, - }; - return res.status(200).json(serverMutation); - } - - const workspaceId = existingNodes[0].workspaceId; - const workspaceAccounts = await prisma.workspaceAccounts.findMany({ - where: { - workspaceId, - }, - }); - - if (workspaceAccounts.length === 0) { - return res.status(401).json({ success: false }); - } - - const accountIds = workspaceAccounts.map((account) => account.accountId); - if (!accountIds.includes(req.accountId)) { - return res.status(401).json({ success: false }); - } - - const accountDevices = await prisma.accountDevices.findMany({ - where: { - accountId: { - in: accountIds, - }, - }, - }); - const deviceIds = accountDevices.map((device) => device.id); - - const result = await prisma.$transaction(async (tx) => { - await tx.nodes.deleteMany({ - where: { - id: { - in: nodesToDelete, - }, - }, - }); - - const serverMutation: ServerDeleteNodesMutation = { - id: NeuronId.generate(NeuronId.Type.Mutation), - type: 'delete_nodes', - workspaceId: workspaceId, - createdAt: new Date().toISOString(), - data: { - ids, - }, - }; - - await tx.mutations.create({ - data: { - ...serverMutation, - devices: deviceIds, - deviceId: localMutation.deviceId, - }, - }); - }); - - return res.status(200).json(result); -}; - -const buildNode = (data: LocalCreateNodeData): Node => { - return { - id: data.id, - parentId: data.parentId, - workspaceId: data.workspaceId, - type: data.type, - index: data.index, - attrs: data.attrs as Record, - createdAt: data.createdAt, - createdBy: data.createdBy, - versionId: data.versionId, - content: data.content as NodeBlock[], - serverCreatedAt: new Date().toISOString(), - serverVersionId: data.versionId, - }; + await database.deleteFrom('nodes').where('id', '=', nodeData.id).execute(); }; diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts index d67b5a02..f1667f45 100644 --- a/server/src/routes/sync.ts +++ b/server/src/routes/sync.ts @@ -1,7 +1,7 @@ import { NeuronRequest, NeuronResponse } from '@/types/api'; -import { prisma } from '@/data/prisma'; +import { database } from '@/data/database'; import { Router } from 'express'; -import { Node, NodeBlock } from '@/types/nodes'; +import { ServerNode } from '@/types/nodes'; export const syncRouter = Router(); @@ -9,29 +9,28 @@ syncRouter.get( '/:workspaceId/sync', async (req: NeuronRequest, res: NeuronResponse) => { const workspaceId = req.params.workspaceId as string; - const nodes = await prisma.nodes.findMany({ - where: { - workspaceId, - }, - }); + const nodes = await database + .selectFrom('nodes') + .selectAll() + .where('workspace_id', '=', workspaceId) + .execute(); - const outputs: Node[] = nodes.map((node) => { + const outputs: ServerNode[] = nodes.map((node) => { return { id: node.id, - parentId: node.parentId, - workspaceId: node.workspaceId, + parentId: node.parent_id, + workspaceId: node.workspace_id, type: node.type, index: node.index, - attrs: node.attrs as Record, - createdAt: node.createdAt.toISOString(), - createdBy: node.createdBy, - versionId: node.versionId, - content: node.content as NodeBlock[], - updatedAt: node.updatedAt?.toISOString(), - updatedBy: node.updatedBy, - serverCreatedAt: node.serverCreatedAt.toISOString(), - serverUpdatedAt: node.serverUpdatedAt?.toISOString(), - serverVersionId: node.serverVersionId, + attrs: node.attrs, + createdAt: node.created_at.toISOString(), + createdBy: node.created_by, + versionId: node.version_id, + content: node.content, + updatedAt: node.updated_at?.toISOString(), + updatedBy: node.updated_by, + serverCreatedAt: node.server_created_at.toISOString(), + serverUpdatedAt: node.server_updated_at?.toISOString(), }; }); diff --git a/server/src/routes/workspaces.ts b/server/src/routes/workspaces.ts index 399851d5..08e6cee0 100644 --- a/server/src/routes/workspaces.ts +++ b/server/src/routes/workspaces.ts @@ -9,7 +9,7 @@ import { } from '@/types/workspaces'; import { ApiError, NeuronRequest, NeuronResponse } from '@/types/api'; import { NeuronId } from '@/lib/id'; -import { prisma } from '@/data/prisma'; +import { database } from '@/data/database'; import { Router } from 'express'; export const workspacesRouter = Router(); @@ -31,11 +31,11 @@ workspacesRouter.post('/', async (req: NeuronRequest, res: NeuronResponse) => { }); } - const account = await prisma.accounts.findUnique({ - where: { - id: req.accountId, - }, - }); + const account = await database + .selectFrom('accounts') + .selectAll() + .where('id', '=', req.accountId) + .executeTakeFirst(); if (!account) { return res.status(404).json({ @@ -68,31 +68,53 @@ workspacesRouter.post('/', async (req: NeuronRequest, res: NeuronResponse) => { versionId: NeuronId.generate(NeuronId.Type.Version), }; - await prisma.$transaction([ - prisma.workspaces.create({ - data: workspace, - }), - prisma.nodes.create({ - data: { + await database.transaction().execute(async (trx) => { + await trx + .insertInto('workspaces') + .values({ + id: workspace.id, + name: workspace.name, + description: workspace.description, + avatar: workspace.avatar, + created_at: workspace.createdAt, + created_by: workspace.createdBy, + status: workspace.status, + version_id: workspace.versionId, + }) + .execute(); + + await trx + .insertInto('nodes') + .values({ id: userId, - workspaceId: workspace.id, + workspace_id: workspace.id, type: 'user', - attrs: { + attrs: JSON.stringify({ accountId: account.id, name: account.name, avatar: account.avatar, - }, - createdAt: new Date(), - createdBy: userId, - versionId: userVersionId, - serverCreatedAt: new Date(), - serverVersionId: userVersionId, - }, - }), - prisma.workspaceAccounts.create({ - data: workspaceAccount, - }), - ]); + }), + created_at: workspaceAccount.createdAt, + created_by: workspaceAccount.createdBy, + version_id: userVersionId, + server_created_at: new Date(), + }) + .execute(); + + await trx + .insertInto('workspace_accounts') + .values({ + account_id: workspaceAccount.accountId, + workspace_id: workspaceAccount.workspaceId, + user_id: workspaceAccount.userId, + role: workspaceAccount.role, + created_at: workspaceAccount.createdAt, + created_by: workspaceAccount.createdBy, + status: workspaceAccount.status, + version_id: workspaceAccount.versionId, + }) + .execute(); + }); const output: WorkspaceOutput = { id: workspace.id, @@ -121,11 +143,11 @@ workspacesRouter.put( }); } - const workspace = await prisma.workspaces.findUnique({ - where: { - id: id, - }, - }); + const workspace = await database + .selectFrom('workspaces') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); if (!workspace) { return res.status(404).json({ @@ -134,12 +156,12 @@ workspacesRouter.put( }); } - const workspaceAccount = await prisma.workspaceAccounts.findFirst({ - where: { - workspaceId: id, - accountId: req.accountId, - }, - }); + const workspaceAccount = await database + .selectFrom('workspace_accounts') + .selectAll() + .where('workspace_id', '=', id) + .where('account_id', '=', req.accountId) + .executeTakeFirst(); if (!workspaceAccount) { return res.status(403).json({ @@ -155,26 +177,47 @@ workspacesRouter.put( }); } - const updatedWorkspace = await prisma.workspaces.update({ - where: { - id: id, - }, - data: { + if (!workspaceAccount) { + return res.status(403).json({ + code: ApiError.Forbidden, + message: 'Forbidden.', + }); + } + + if (workspaceAccount.role !== WorkspaceRole.Owner) { + return res.status(403).json({ + code: ApiError.Forbidden, + message: 'Forbidden.', + }); + } + + const updatedWorkspace = await database + .updateTable('workspaces') + .set({ name: input.name, - updatedAt: new Date(), - updatedBy: req.accountId, - }, - }); + updated_at: new Date(), + updated_by: req.accountId, + }) + .where('id', '=', id) + .returningAll() + .executeTakeFirst(); + + if (!updatedWorkspace) { + return res.status(500).json({ + code: ApiError.InternalServerError, + message: 'Internal server error.', + }); + } const output: WorkspaceOutput = { id: updatedWorkspace.id, name: updatedWorkspace.name, description: updatedWorkspace.description, avatar: updatedWorkspace.avatar, - versionId: updatedWorkspace.versionId, + versionId: updatedWorkspace.version_id, accountId: req.accountId, role: workspaceAccount.role, - userId: workspaceAccount.userId, + userId: workspaceAccount.user_id, }; return res.status(200).json(output); @@ -193,11 +236,11 @@ workspacesRouter.delete( }); } - const workspace = await prisma.workspaces.findUnique({ - where: { - id: id, - }, - }); + const workspace = await database + .selectFrom('workspaces') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); if (!workspace) { return res.status(404).json({ @@ -206,12 +249,12 @@ workspacesRouter.delete( }); } - const workspaceAccount = await prisma.workspaceAccounts.findFirst({ - where: { - workspaceId: id, - accountId: req.accountId, - }, - }); + const workspaceAccount = await database + .selectFrom('workspace_accounts') + .selectAll() + .where('workspace_id', '=', id) + .where('account_id', '=', req.accountId) + .executeTakeFirst(); if (!workspaceAccount) { return res.status(403).json({ @@ -220,11 +263,16 @@ workspacesRouter.delete( }); } - await prisma.workspaces.delete({ - where: { - id: id, - }, - }); + if (workspaceAccount.role !== WorkspaceRole.Owner) { + return res.status(403).json({ + code: ApiError.Forbidden, + message: 'Forbidden.', + }); + } + + await database.deleteFrom('workspaces').where('id', '=', id).execute(); + + await database.deleteFrom('workspaces').where('id', '=', id).execute(); return res.status(200).json({ id: workspace.id, @@ -242,11 +290,11 @@ workspacesRouter.get(':id', async (req: NeuronRequest, res: NeuronResponse) => { }); } - const workspace = await prisma.workspaces.findUnique({ - where: { - id: id, - }, - }); + const workspace = await database + .selectFrom('workspaces') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); if (!workspace) { return res.status(404).json({ @@ -255,12 +303,12 @@ workspacesRouter.get(':id', async (req: NeuronRequest, res: NeuronResponse) => { }); } - const workspaceAccount = await prisma.workspaceAccounts.findFirst({ - where: { - workspaceId: id, - accountId: req.accountId, - }, - }); + const workspaceAccount = await database + .selectFrom('workspace_accounts') + .selectAll() + .where('workspace_id', '=', id) + .where('account_id', '=', req.accountId) + .executeTakeFirst(); if (!workspaceAccount) { return res.status(403).json({ @@ -274,10 +322,10 @@ workspacesRouter.get(':id', async (req: NeuronRequest, res: NeuronResponse) => { name: workspace.name, description: workspace.description, avatar: workspace.avatar, - versionId: workspace.versionId, + versionId: workspace.version_id, accountId: req.accountId, role: workspaceAccount.role, - userId: workspaceAccount.userId, + userId: workspaceAccount.user_id, }; return res.status(200).json(output); @@ -291,26 +339,24 @@ workspacesRouter.get('/', async (req: NeuronRequest, res: NeuronResponse) => { }); } - const workspaceAccounts = await prisma.workspaceAccounts.findMany({ - where: { - accountId: req.accountId, - }, - }); + const workspaceAccounts = await database + .selectFrom('workspace_accounts') + .selectAll() + .where('account_id', '=', req.accountId) + .execute(); - const workspaceIds = workspaceAccounts.map((wa) => wa.workspaceId); - const workspaces = await prisma.workspaces.findMany({ - where: { - id: { - in: workspaceIds, - }, - }, - }); + const workspaceIds = workspaceAccounts.map((wa) => wa.workspace_id); + const workspaces = await database + .selectFrom('workspaces') + .selectAll() + .where('id', 'in', workspaceIds) + .execute(); const outputs: WorkspaceOutput[] = []; for (const workspace of workspaces) { const workspaceAccount = workspaceAccounts.find( - (wa) => wa.workspaceId === workspace.id, + (wa) => wa.workspace_id === workspace.id, ); if (!workspaceAccount) { @@ -322,10 +368,10 @@ workspacesRouter.get('/', async (req: NeuronRequest, res: NeuronResponse) => { name: workspace.name, description: workspace.description, avatar: workspace.avatar, - versionId: workspace.versionId, + versionId: workspace.version_id, accountId: req.accountId, role: workspaceAccount.role, - userId: workspaceAccount.userId, + userId: workspaceAccount.user_id, }; outputs.push(output); diff --git a/server/src/types/changes.ts b/server/src/types/changes.ts index a227dc03..e2ad1fa6 100644 --- a/server/src/types/changes.ts +++ b/server/src/types/changes.ts @@ -25,30 +25,30 @@ type ChangeSource = { lsn: number; }; +export type MutationChangeData = { + id: string; + workspace_id: string; + table: string; + action: string; + after: string | null; + before: string | null; + created_at: string; + device_ids: string[]; +}; + export type NodeChangeData = { id: string; workspace_id: string; - parent_id?: string | null; + parent_id: string | null; type: string; index: string | null; - attrs?: string | null; - content?: string | null; + attrs: string | null; + content: string | null; created_at: string; created_by: string; - updated_at?: string | null; - updated_by?: string | null; + updated_at: string | null; + updated_by: string | null; version_id: string; server_created_at: string; - server_updated_at?: string | null; - server_version_id: string; -}; - -export type MutationChangeData = { - id: string; - type: string; - workspace_id: string; - data: string; - created_at: string; - device_id: string; - devices: string[] | null; + server_updated_at: string | null; }; diff --git a/server/src/types/mutations.ts b/server/src/types/mutations.ts index 4248047d..60aac21f 100644 --- a/server/src/types/mutations.ts +++ b/server/src/types/mutations.ts @@ -1,131 +1,39 @@ -import { Node, NodeBlock } from '@/types/nodes'; - -export type LocalMutation = - | LocalCreateNodeMutation - | LocalCreateNodesMutation - | LocalUpdateNodeMutation - | LocalDeleteNodeMutation - | LocalDeleteNodesMutation; - -export type LocalCreateNodeMutation = { - type: 'create_node'; +export type ExecuteLocalMutationsInput = { workspaceId: string; - deviceId: string; - data: { - node: LocalCreateNodeData; - }; + mutations: LocalMutation[]; }; -export type LocalCreateNodesMutation = { - type: 'create_nodes'; - workspaceId: string; - deviceId: string; - data: { - nodes: LocalCreateNodeData[]; - }; +export type LocalMutation = { + id: number; + table: string; + action: 'insert' | 'update' | 'delete'; + before?: string | null; + after?: string | null; + createdAt: string; }; -export type LocalCreateNodeData = { +export type LocalNodeMutationData = { id: string; type: string; - parentId: string | null; - workspaceId: string; + parent_id: string | null; index: string | null; - content: NodeBlock[]; - attrs: Record; - createdAt: string; - createdBy: string; - versionId: string; + attrs?: string | null; + content?: string | null; + created_at: string; + updated_at?: string | null; + created_by: string; + updated_by?: string | null; + version_id: string; + server_created_at: string; + server_updated_at: string; + server_version_id: string; }; -export type LocalUpdateNodeMutation = { - type: 'update_node'; - workspaceId: string; - deviceId: string; - data: { - id: string; - type: string; - parentId: string | null; - index: string | null; - content: NodeBlock[]; - attrs: Record; - updatedAt: string; - updatedBy: string; - versionId: string; - }; -}; - -export type LocalDeleteNodeMutation = { - type: 'delete_node'; - workspaceId: string; - deviceId: string; - data: { - id: string; - }; -}; - -export type LocalDeleteNodesMutation = { - type: 'delete_nodes'; - workspaceId: string; - deviceId: string; - data: { - ids: string[]; - }; -}; - -export type ServerMutation = - | ServerCreateNodeMutation - | ServerCreateNodesMutation - | ServerUpdateNodeMutation - | ServerDeleteNodeMutation - | ServerDeleteNodesMutation; - -export type ServerCreateNodeMutation = { +export type ServerMutation = { id: string; - type: 'create_node'; + table: string; + action: 'insert' | 'update' | 'delete'; workspaceId: string; - data: { - node: Node; - }; - createdAt: string; -}; - -export type ServerCreateNodesMutation = { - id: string; - type: 'create_nodes'; - workspaceId: string; - data: { - nodes: Node[]; - }; - createdAt: string; -}; - -export type ServerUpdateNodeMutation = { - id: string; - type: 'update_node'; - workspaceId: string; - data: { - node: Node; - }; - createdAt: string; -}; - -export type ServerDeleteNodeMutation = { - id: string; - type: 'delete_node'; - workspaceId: string; - data: { - id: string; - }; - createdAt: string; -}; - -export type ServerDeleteNodesMutation = { - id: string; - type: 'delete_nodes'; - workspaceId: string; - data: { - ids: string[]; - }; - createdAt: string; + before: any | null; + after: any | null; }; diff --git a/server/src/types/nodes.ts b/server/src/types/nodes.ts index 71d5dfae..203d9b92 100644 --- a/server/src/types/nodes.ts +++ b/server/src/types/nodes.ts @@ -1,4 +1,4 @@ -export type Node = { +export type ServerNode = { id: string; workspaceId: string; parentId?: string | null; @@ -13,7 +13,6 @@ export type Node = { versionId: string; serverCreatedAt: string; serverUpdatedAt?: string | null; - serverVersionId: string; }; export type NodeBlock = {