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 { GoogleLoginMutationHandler } from './auth/google-login';
import { AvatarUploadMutationHandler } from './avatars/avatar-upload';
import { ChannelCreateMutationHandler } from './channels/channel-create';
import { ChannelUpdateMutationHandler } from './channels/channel-update';
import { ChatCreateMutationHandler } from './chats/chat-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 { NodeCollaboratorDeleteMutationHandler } from './nodes/node-collaborator-delete';
import { NodeCollaboratorUpdateMutationHandler } from './nodes/node-collaborator-update';
import { NodeCreateMutationHandler } from './nodes/node-create';
import { NodeDeleteMutationHandler } from './nodes/node-delete';
import { NodeInteractionOpenedMutationHandler } from './nodes/node-interaction-opened';
import { NodeInteractionSeenMutationHandler } from './nodes/node-interaction-seen';
@@ -80,8 +80,8 @@ export const buildMutationHandlerMap = (
'email.verify': new EmailVerifyMutationHandler(app),
'google.login': new GoogleLoginMutationHandler(app),
'view.create': new ViewCreateMutationHandler(app),
'channel.create': new ChannelCreateMutationHandler(app),
'node.delete': new NodeDeleteMutationHandler(app),
'node.create': new NodeCreateMutationHandler(app),
'chat.create': new ChatCreateMutationHandler(app),
'database.create': new DatabaseCreateMutationHandler(app),
'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-update';
export * from './avatars/avatar-upload';
export * from './channels/channel-create';
export * from './channels/channel-update';
export * from './chats/chat-create';
export * from './databases/database-create';
@@ -62,6 +61,7 @@ export * from './apps/tab-update';
export * from './servers/server-sync';
export * from './apps/tab-delete';
export * from './nodes/node-delete';
export * from './nodes/node-create';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
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';
import { deleteNodeRelations, fetchNodeTree } from '@colanode/client/lib/utils';
import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
import { DownloadStatus } from '@colanode/client/types';
import { DownloadStatus, LocalNode } from '@colanode/client/types';
import {
generateId,
IdType,
@@ -225,6 +225,173 @@ export class NodeService {
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>(
nodeId: string,
updater: (attributes: T) => T

View File

@@ -60,15 +60,15 @@ export const createNodesCollection = (userId: string) => {
};
},
},
// onInsert: async ({ transaction }) => {
// for (const mutation of transaction.mutations) {
// await window.colanode.executeMutation({
// type: 'node.create',
// userId,
// node: mutation.modified,
// });
// }
// },
onInsert: async ({ transaction }) => {
for (const mutation of transaction.mutations) {
await window.colanode.executeMutation({
type: 'node.create',
userId,
node: mutation.modified,
});
}
},
// onUpdate: async ({ transaction }) => {
// for (const mutation of transaction.mutations) {
// 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 { toast } from 'sonner';
import { LocalChannelNode } from '@colanode/client/types';
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 {
Dialog,
DialogContent,
@@ -11,7 +17,6 @@ import {
DialogTitle,
} from '@colanode/ui/components/ui/dialog';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
interface ChannelCreateDialogProps {
spaceId: string;
@@ -26,7 +31,46 @@ export const ChannelCreateDialog = ({
}: ChannelCreateDialogProps) => {
const workspace = useWorkspace();
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -42,38 +86,11 @@ export const ChannelCreateDialog = ({
values={{
name: '',
}}
isPending={isPending}
submitText="Create"
handleCancel={() => {
onCancel={() => {
onOpenChange(false);
}}
handleSubmit={(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);
},
});
}}
onSubmit={(values) => mutate(values)}
/>
</DialogContent>
</Dialog>

View File

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

View File

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