Use tanstackdb for channel creation

This commit is contained in:
Hakan Shehu
2025-11-20 18:08:28 -08:00
parent f881e153b5
commit 8b233ca046
11 changed files with 282 additions and 138 deletions

View File

@@ -1,50 +0,0 @@
import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base';
import { MutationHandler } from '@colanode/client/lib/types';
import { MutationError, MutationErrorCode } from '@colanode/client/mutations';
import {
ChannelCreateMutationInput,
ChannelCreateMutationOutput,
} from '@colanode/client/mutations/channels/channel-create';
import { ChannelAttributes, generateId, IdType } from '@colanode/core';
export class ChannelCreateMutationHandler
extends WorkspaceMutationHandlerBase
implements MutationHandler<ChannelCreateMutationInput>
{
async handleMutation(
input: ChannelCreateMutationInput
): Promise<ChannelCreateMutationOutput> {
const workspace = this.getWorkspace(input.userId);
const space = await workspace.database
.selectFrom('nodes')
.selectAll()
.where('id', '=', input.spaceId)
.executeTakeFirst();
if (!space) {
throw new MutationError(
MutationErrorCode.SpaceNotFound,
'Space not found or has been deleted.'
);
}
const id = generateId(IdType.Channel);
const attributes: ChannelAttributes = {
type: 'channel',
name: input.name,
avatar: input.avatar,
parentId: input.spaceId,
};
await workspace.nodes.createNode({
id,
attributes,
parentId: input.spaceId,
});
return {
id: id,
};
}
}

View File

@@ -16,7 +16,6 @@ import { EmailRegisterMutationHandler } from './auth/email-register';
import { EmailVerifyMutationHandler } from './auth/email-verify'; import { EmailVerifyMutationHandler } from './auth/email-verify';
import { GoogleLoginMutationHandler } from './auth/google-login'; import { GoogleLoginMutationHandler } from './auth/google-login';
import { AvatarUploadMutationHandler } from './avatars/avatar-upload'; import { AvatarUploadMutationHandler } from './avatars/avatar-upload';
import { ChannelCreateMutationHandler } from './channels/channel-create';
import { ChannelUpdateMutationHandler } from './channels/channel-update'; import { ChannelUpdateMutationHandler } from './channels/channel-update';
import { ChatCreateMutationHandler } from './chats/chat-create'; import { ChatCreateMutationHandler } from './chats/chat-create';
import { DatabaseCreateMutationHandler } from './databases/database-create'; import { DatabaseCreateMutationHandler } from './databases/database-create';
@@ -42,6 +41,7 @@ import { MessageCreateMutationHandler } from './messages/message-create';
import { NodeCollaboratorCreateMutationHandler } from './nodes/node-collaborator-create'; import { NodeCollaboratorCreateMutationHandler } from './nodes/node-collaborator-create';
import { NodeCollaboratorDeleteMutationHandler } from './nodes/node-collaborator-delete'; import { NodeCollaboratorDeleteMutationHandler } from './nodes/node-collaborator-delete';
import { NodeCollaboratorUpdateMutationHandler } from './nodes/node-collaborator-update'; import { NodeCollaboratorUpdateMutationHandler } from './nodes/node-collaborator-update';
import { NodeCreateMutationHandler } from './nodes/node-create';
import { NodeDeleteMutationHandler } from './nodes/node-delete'; import { NodeDeleteMutationHandler } from './nodes/node-delete';
import { NodeInteractionOpenedMutationHandler } from './nodes/node-interaction-opened'; import { NodeInteractionOpenedMutationHandler } from './nodes/node-interaction-opened';
import { NodeInteractionSeenMutationHandler } from './nodes/node-interaction-seen'; import { NodeInteractionSeenMutationHandler } from './nodes/node-interaction-seen';
@@ -80,8 +80,8 @@ export const buildMutationHandlerMap = (
'email.verify': new EmailVerifyMutationHandler(app), 'email.verify': new EmailVerifyMutationHandler(app),
'google.login': new GoogleLoginMutationHandler(app), 'google.login': new GoogleLoginMutationHandler(app),
'view.create': new ViewCreateMutationHandler(app), 'view.create': new ViewCreateMutationHandler(app),
'channel.create': new ChannelCreateMutationHandler(app),
'node.delete': new NodeDeleteMutationHandler(app), 'node.delete': new NodeDeleteMutationHandler(app),
'node.create': new NodeCreateMutationHandler(app),
'chat.create': new ChatCreateMutationHandler(app), 'chat.create': new ChatCreateMutationHandler(app),
'database.create': new DatabaseCreateMutationHandler(app), 'database.create': new DatabaseCreateMutationHandler(app),
'database.name.field.update': new DatabaseNameFieldUpdateMutationHandler( 'database.name.field.update': new DatabaseNameFieldUpdateMutationHandler(

View File

@@ -0,0 +1,22 @@
import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base';
import { MutationHandler } from '@colanode/client/lib/types';
import {
NodeCreateMutationInput,
NodeCreateMutationOutput,
} from '@colanode/client/mutations/nodes/node-create';
export class NodeCreateMutationHandler
extends WorkspaceMutationHandlerBase
implements MutationHandler<NodeCreateMutationInput>
{
async handleMutation(
input: NodeCreateMutationInput
): Promise<NodeCreateMutationOutput> {
const workspace = this.getWorkspace(input.userId);
await workspace.nodes.insertNode(input.node);
return {
success: true,
};
}
}

View File

@@ -1,20 +0,0 @@
export type ChannelCreateMutationInput = {
type: 'channel.create';
userId: string;
spaceId: string;
name: string;
avatar?: string | null;
};
export type ChannelCreateMutationOutput = {
id: string;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'channel.create': {
input: ChannelCreateMutationInput;
output: ChannelCreateMutationOutput;
};
}
}

View File

@@ -9,7 +9,6 @@ export * from './auth/google-login';
export * from './apps/metadata-delete'; export * from './apps/metadata-delete';
export * from './apps/metadata-update'; export * from './apps/metadata-update';
export * from './avatars/avatar-upload'; export * from './avatars/avatar-upload';
export * from './channels/channel-create';
export * from './channels/channel-update'; export * from './channels/channel-update';
export * from './chats/chat-create'; export * from './chats/chat-create';
export * from './databases/database-create'; export * from './databases/database-create';
@@ -62,6 +61,7 @@ export * from './apps/tab-update';
export * from './servers/server-sync'; export * from './servers/server-sync';
export * from './apps/tab-delete'; export * from './apps/tab-delete';
export * from './nodes/node-delete'; export * from './nodes/node-delete';
export * from './nodes/node-create';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type // eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface MutationMap {} export interface MutationMap {}

View File

@@ -0,0 +1,20 @@
import { LocalNode } from '@colanode/client/types';
export type NodeCreateMutationInput = {
type: 'node.create';
userId: string;
node: LocalNode;
};
export type NodeCreateMutationOutput = {
success: boolean;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'node.create': {
input: NodeCreateMutationInput;
output: NodeCreateMutationOutput;
};
}
}

View File

@@ -16,7 +16,7 @@ import {
} from '@colanode/client/lib/mentions'; } from '@colanode/client/lib/mentions';
import { deleteNodeRelations, fetchNodeTree } from '@colanode/client/lib/utils'; import { deleteNodeRelations, fetchNodeTree } from '@colanode/client/lib/utils';
import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
import { DownloadStatus } from '@colanode/client/types'; import { DownloadStatus, LocalNode } from '@colanode/client/types';
import { import {
generateId, generateId,
IdType, IdType,
@@ -225,6 +225,173 @@ export class NodeService {
return createdNode; return createdNode;
} }
public async insertNode(input: LocalNode): Promise<LocalNode> {
debug(`Inserting node ${input.id} with type ${input.attributes.type}`);
const tree = input.parentId
? await fetchNodeTree(this.workspace.database, input.parentId)
: [];
const model = getNodeModel(input.attributes.type);
const canCreateNodeContext: CanCreateNodeContext = {
user: {
id: this.workspace.userId,
role: this.workspace.role,
workspaceId: this.workspace.workspaceId,
accountId: this.workspace.accountId,
},
tree: tree,
attributes: input.attributes,
};
if (!model.canCreate(canCreateNodeContext)) {
throw new Error('Insufficient permissions');
}
const ydoc = new YDoc();
const update = ydoc.update(model.attributesSchema, input.attributes);
if (!update) {
throw new Error('Invalid attributes');
}
const updateId = generateId(IdType.Update);
const createdAt = new Date().toISOString();
const rootId = tree[0]?.id ?? input.id;
const nodeText = model.extractText(input.id, input.attributes);
const mentions = model.extractMentions(input.id, input.attributes);
const nodeReferencesToCreate: CreateNodeReference[] = mentions.map(
(mention) => ({
node_id: input.id,
reference_id: mention.target,
inner_id: mention.id,
type: 'mention',
created_at: createdAt,
created_by: this.workspace.userId,
})
);
const { createdNode, createdMutation, createdNodeReferences } =
await this.workspace.database.transaction().execute(async (trx) => {
const createdNode = await trx
.insertInto('nodes')
.returningAll()
.values({
id: input.id,
root_id: rootId,
attributes: JSON.stringify(input.attributes),
created_at: createdAt,
created_by: this.workspace.userId,
local_revision: '0',
server_revision: '0',
})
.executeTakeFirst();
if (!createdNode) {
throw new Error('Failed to create node');
}
const createdNodeUpdate = await trx
.insertInto('node_updates')
.returningAll()
.values({
id: updateId,
node_id: input.id,
data: update,
created_at: createdAt,
})
.executeTakeFirst();
if (!createdNodeUpdate) {
throw new Error('Failed to create node update');
}
const mutationData: CreateNodeMutationData = {
nodeId: input.id,
updateId: updateId,
data: encodeState(update),
createdAt: createdAt,
};
const createdMutation = await trx
.insertInto('mutations')
.returningAll()
.values({
id: generateId(IdType.Mutation),
type: 'node.create',
data: JSON.stringify(mutationData),
created_at: createdAt,
retries: 0,
})
.executeTakeFirst();
if (!createdMutation) {
throw new Error('Failed to create mutation');
}
if (nodeText) {
await trx
.insertInto('node_texts')
.values({
id: input.id,
name: nodeText.name,
attributes: nodeText.attributes,
})
.execute();
}
let createdNodeReferences: SelectNodeReference[] = [];
if (nodeReferencesToCreate.length > 0) {
createdNodeReferences = await trx
.insertInto('node_references')
.values(nodeReferencesToCreate)
.returningAll()
.execute();
}
return {
createdNode,
createdMutation,
createdNodeReferences,
};
});
if (!createdNode) {
throw new Error('Failed to create node');
}
if (!createdMutation) {
throw new Error('Failed to create mutation');
}
debug(`Created node ${createdNode.id} with type ${createdNode.type}`);
eventBus.publish({
type: 'node.created',
workspace: {
workspaceId: this.workspace.workspaceId,
userId: this.workspace.userId,
accountId: this.workspace.accountId,
},
node: mapNode(createdNode),
});
for (const createdNodeReference of createdNodeReferences) {
eventBus.publish({
type: 'node.reference.created',
workspace: {
workspaceId: this.workspace.workspaceId,
userId: this.workspace.userId,
accountId: this.workspace.accountId,
},
nodeReference: mapNodeReference(createdNodeReference),
});
}
this.workspace.mutations.scheduleSync();
return mapNode(createdNode);
}
public async updateNode<T extends NodeAttributes>( public async updateNode<T extends NodeAttributes>(
nodeId: string, nodeId: string,
updater: (attributes: T) => T updater: (attributes: T) => T

View File

@@ -60,15 +60,15 @@ export const createNodesCollection = (userId: string) => {
}; };
}, },
}, },
// onInsert: async ({ transaction }) => { onInsert: async ({ transaction }) => {
// for (const mutation of transaction.mutations) { for (const mutation of transaction.mutations) {
// await window.colanode.executeMutation({ await window.colanode.executeMutation({
// type: 'node.create', type: 'node.create',
// userId, userId,
// node: mutation.modified, node: mutation.modified,
// }); });
// } }
// }, },
// onUpdate: async ({ transaction }) => { // onUpdate: async ({ transaction }) => {
// for (const mutation of transaction.mutations) { // for (const mutation of transaction.mutations) {
// const attributes = cloneDeep(mutation.modified.attributes); // const attributes = cloneDeep(mutation.modified.attributes);

View File

@@ -1,8 +1,14 @@
import { useMutation } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { LocalChannelNode } from '@colanode/client/types';
import { generateId, IdType } from '@colanode/core'; import { generateId, IdType } from '@colanode/core';
import { ChannelForm } from '@colanode/ui/components/channels/channel-form'; import { collections } from '@colanode/ui/collections';
import {
ChannelForm,
ChannelFormValues,
} from '@colanode/ui/components/channels/channel-form';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,7 +17,6 @@ import {
DialogTitle, DialogTitle,
} from '@colanode/ui/components/ui/dialog'; } from '@colanode/ui/components/ui/dialog';
import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
interface ChannelCreateDialogProps { interface ChannelCreateDialogProps {
spaceId: string; spaceId: string;
@@ -26,7 +31,46 @@ export const ChannelCreateDialog = ({
}: ChannelCreateDialogProps) => { }: ChannelCreateDialogProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
const navigate = useNavigate({ from: '/workspace/$userId' }); const navigate = useNavigate({ from: '/workspace/$userId' });
const { mutate, isPending } = useMutation();
const { mutate } = useMutation({
mutationFn: async (values: ChannelFormValues) => {
const channelId = generateId(IdType.Channel);
const nodes = collections.workspace(workspace.userId).nodes;
const channel: LocalChannelNode = {
id: channelId,
type: 'channel',
attributes: {
type: 'channel',
name: values.name,
parentId: spaceId,
},
parentId: spaceId,
rootId: spaceId,
createdAt: new Date().toISOString(),
createdBy: workspace.userId,
updatedAt: null,
updatedBy: null,
localRevision: '0',
serverRevision: '0',
};
nodes.insert(channel);
return channel;
},
onSuccess: (channel) => {
navigate({
to: '$nodeId',
params: {
nodeId: channel.id,
},
});
onOpenChange(false);
},
onError: (error) => {
toast.error(error.message);
},
});
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -42,38 +86,11 @@ export const ChannelCreateDialog = ({
values={{ values={{
name: '', name: '',
}} }}
isPending={isPending}
submitText="Create" submitText="Create"
handleCancel={() => { onCancel={() => {
onOpenChange(false); onOpenChange(false);
}} }}
handleSubmit={(values) => { onSubmit={(values) => mutate(values)}
if (isPending) {
return;
}
mutate({
input: {
type: 'channel.create',
spaceId: spaceId,
name: values.name,
avatar: values.avatar,
userId: workspace.userId,
},
onSuccess(output) {
onOpenChange(false);
navigate({
to: '$nodeId',
params: {
nodeId: output.id,
},
});
},
onError(error) {
toast.error(error.message);
},
});
}}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -14,30 +14,29 @@ import {
FormMessage, FormMessage,
} from '@colanode/ui/components/ui/form'; } from '@colanode/ui/components/ui/form';
import { Input } from '@colanode/ui/components/ui/input'; import { Input } from '@colanode/ui/components/ui/input';
import { Spinner } from '@colanode/ui/components/ui/spinner';
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(3, 'Name must be at least 3 characters long.'), name: z.string().min(3, 'Name must be at least 3 characters long.'),
avatar: z.string().optional().nullable(), avatar: z.string().optional().nullable(),
}); });
export type ChannelFormValues = z.infer<typeof formSchema>;
interface ChannelFormProps { interface ChannelFormProps {
id: string; id: string;
values: z.infer<typeof formSchema>; values: z.infer<typeof formSchema>;
isPending: boolean;
submitText: string; submitText: string;
handleCancel: () => void; onCancel: () => void;
handleSubmit: (values: z.infer<typeof formSchema>) => void; onSubmit: (values: z.infer<typeof formSchema>) => void;
readOnly?: boolean; readOnly?: boolean;
} }
export const ChannelForm = ({ export const ChannelForm = ({
id, id,
values, values,
isPending,
submitText, submitText,
handleCancel, onCancel,
handleSubmit, onSubmit,
readOnly = false, readOnly = false,
}: ChannelFormProps) => { }: ChannelFormProps) => {
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@@ -60,10 +59,7 @@ export const ChannelForm = ({
return ( return (
<Form {...form}> <Form {...form}>
<form <form className="flex flex-col" onSubmit={form.handleSubmit(onSubmit)}>
className="flex flex-col"
onSubmit={form.handleSubmit(handleSubmit)}
>
<div className="grow flex flex-row items-end gap-2 py-2 pb-4"> <div className="grow flex flex-row items-end gap-2 py-2 pb-4">
{readOnly ? ( {readOnly ? (
<Button type="button" variant="outline" size="icon"> <Button type="button" variant="outline" size="icon">
@@ -72,7 +68,6 @@ export const ChannelForm = ({
) : ( ) : (
<AvatarPopover <AvatarPopover
onPick={(avatar) => { onPick={(avatar) => {
if (isPending) return;
if (avatar === values.avatar) return; if (avatar === values.avatar) return;
form.setValue('avatar', avatar); form.setValue('avatar', avatar);
@@ -102,16 +97,10 @@ export const ChannelForm = ({
/> />
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button type="button" variant="outline" onClick={onCancel}>
type="button"
variant="outline"
disabled={isPending}
onClick={handleCancel}
>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isPending || readOnly}> <Button type="submit" disabled={readOnly}>
{isPending && <Spinner className="mr-1" />}
{submitText} {submitText}
</Button> </Button>
</div> </div>

View File

@@ -45,13 +45,12 @@ export const ChannelUpdateDialog = ({
name: channel.attributes.name, name: channel.attributes.name,
avatar: channel.attributes.avatar, avatar: channel.attributes.avatar,
}} }}
isPending={isPending}
submitText="Update" submitText="Update"
readOnly={!canEdit} readOnly={!canEdit}
handleCancel={() => { onCancel={() => {
onOpenChange(false); onOpenChange(false);
}} }}
handleSubmit={(values) => { onSubmit={(values) => {
if (isPending) { if (isPending) {
return; return;
} }