supermemory/apps/web/hooks/use-document-mutations.ts
MaheshtheDev bc9120f751 feat: multi-file upload queue for Add Document (#789)
Adds multi-select and multi-file drag-and-drop on the Add Document Upload files tab, a per-file queue with status, retry for failed rows, and batched uploads to the existing `POST /v3/documents/file` endpoint with a client-side concurrency limit of 3.

Optional title/description apply only when a single file is queued. .md / .mdx (and text/markdown) are accepted.

Upload progress is shown as a bottom strip with a slow width animation
2026-03-19 21:14:17 +00:00

635 lines
17 KiB
TypeScript

"use client"
import {
useMutation,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { useAuth } from "@lib/auth-context"
import { analytics } from "@/lib/analytics"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
interface DocumentWithId {
id?: string
customId?: string | null
}
interface DocumentsQueryData {
documents: DocumentWithId[]
totalCount: number
}
type InfiniteQueryData = {
pages: DocumentsResponse[]
pageParams: number[]
}
interface UseDocumentMutationsOptions {
onClose?: () => void
}
interface OptimisticMemory {
id: string
content: string
url: string | null
title: string
description: string
containerTags: string[]
createdAt: string
updatedAt: string
status: string
type: string
metadata: Record<string, unknown>
memoryEntries: unknown[]
isOptimistic?: boolean
}
function addOptimisticMemoryToQueryData(
old: unknown,
memory: OptimisticMemory,
): unknown {
if (!old || typeof old !== "object") return old
const data = old as Record<string, unknown>
if ("pages" in data && Array.isArray(data.pages)) {
return {
...data,
pages: data.pages.map((page: unknown, index: number) => {
if (index !== 0) return page
const p = page as Record<string, unknown>
if (!p?.documents || !Array.isArray(p.documents)) return page
return {
...p,
documents: [memory, ...p.documents],
pagination: p.pagination
? {
...(p.pagination as Record<string, unknown>),
totalItems:
((p.pagination as Record<string, number>).totalItems ?? 0) +
1,
}
: p.pagination,
}
}),
}
}
if ("documents" in data && Array.isArray(data.documents)) {
return {
...data,
documents: [memory, ...data.documents],
totalCount: ((data.totalCount as number) ?? 0) + 1,
}
}
return old
}
function removeDocumentFromQueryData(
old: unknown,
documentId: string,
): unknown {
if (!old || typeof old !== "object") return old
const data = old as Record<string, unknown>
if ("pages" in data && Array.isArray(data.pages)) {
return {
...data,
pages: data.pages.map((page: unknown) => {
const p = page as Record<string, unknown>
if (!p?.documents || !Array.isArray(p.documents)) return page
return {
...p,
documents: (p.documents as DocumentWithId[]).filter(
(doc) => doc.id !== documentId && doc.customId !== documentId,
),
pagination: p.pagination
? {
...(p.pagination as Record<string, unknown>),
totalItems: Math.max(
0,
((p.pagination as Record<string, number>).totalItems ?? 0) -
1,
),
}
: p.pagination,
}
}),
}
}
if ("documents" in data && Array.isArray(data.documents)) {
return {
...data,
documents: (data.documents as DocumentWithId[]).filter(
(doc) => doc.id !== documentId && doc.customId !== documentId,
),
totalCount: Math.max(0, ((data.totalCount as number) ?? 0) - 1),
}
}
return old
}
function removeDocumentsFromQueryData(
old: unknown,
documentIds: Set<string>,
): unknown {
if (!old || typeof old !== "object" || documentIds.size === 0) return old
const data = old as Record<string, unknown>
if ("pages" in data && Array.isArray(data.pages)) {
return {
...data,
pages: data.pages.map((page: unknown) => {
const p = page as Record<string, unknown>
if (!p?.documents || !Array.isArray(p.documents)) return page
const filtered = (p.documents as DocumentWithId[]).filter(
(doc) =>
!documentIds.has(doc.id ?? "") &&
!documentIds.has(doc.customId ?? ""),
)
const removed =
(p.documents as DocumentWithId[]).length - filtered.length
return {
...p,
documents: filtered,
pagination: p.pagination
? {
...(p.pagination as Record<string, unknown>),
totalItems: Math.max(
0,
((p.pagination as Record<string, number>).totalItems ?? 0) -
removed,
),
}
: p.pagination,
}
}),
}
}
if ("documents" in data && Array.isArray(data.documents)) {
const filtered = (data.documents as DocumentWithId[]).filter(
(doc) =>
!documentIds.has(doc.id ?? "") && !documentIds.has(doc.customId ?? ""),
)
const removed =
(data.documents as DocumentWithId[]).length - filtered.length
return {
...data,
documents: filtered,
totalCount: Math.max(0, ((data.totalCount as number) ?? 0) - removed),
}
}
return old
}
async function cancelAndSnapshotQueries(
queryClient: QueryClient,
): Promise<[unknown, unknown][]> {
await queryClient.cancelQueries({ queryKey: ["documents-with-memories"] })
return queryClient.getQueriesData({ queryKey: ["documents-with-memories"] })
}
function restoreQueriesFromSnapshot(
queryClient: QueryClient,
previousQueries: [unknown, unknown][] | undefined,
): void {
if (!previousQueries) return
for (const [queryKey, data] of previousQueries) {
queryClient.setQueryData(queryKey as unknown[], data)
}
}
const FILE_UPLOAD_CONCURRENCY = 3
export type FileUploadEntry = { id: string; file: File }
export type FileUploadBatchResult = {
failures: { id: string; message: string }[]
successCount: number
}
export function useDocumentMutations({
onClose,
}: UseDocumentMutationsOptions = {}) {
const queryClient = useQueryClient()
const { user } = useAuth()
const entityContext = `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`
const noteMutation = useMutation({
mutationFn: async ({
content,
project,
}: {
content: string
project: string
}) => {
const response = await $fetch("@post/documents", {
body: {
content,
containerTags: [project],
entityContext,
metadata: { sm_source: "consumer" },
},
})
if (response.error) {
throw new Error(response.error?.message || "Failed to add note")
}
return response.data
},
onMutate: async ({ content, project }) => {
const previousQueries = await cancelAndSnapshotQueries(queryClient)
const now = new Date().toISOString()
const optimisticMemory: OptimisticMemory = {
id: `temp-${crypto.randomUUID()}`,
content,
url: null,
title: content.substring(0, 100),
description: "Processing content...",
containerTags: [project],
createdAt: now,
updatedAt: now,
status: "queued",
type: "note",
metadata: {
processingStage: "queued",
processingMessage: "Added to processing queue",
},
memoryEntries: [],
isOptimistic: true,
}
queryClient.setQueriesData(
{ queryKey: ["documents-with-memories"] },
(old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
)
return { previousQueries }
},
onError: (error, _variables, context) => {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
toast.error("Failed to add note", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
analytics.documentAdded({ type: "note", project_id: variables.project })
toast.success("Note added successfully!", {
description: "Your note is being processed",
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
onClose?.()
},
})
const linkMutation = useMutation({
mutationFn: async ({ url, project }: { url: string; project: string }) => {
const response = await $fetch("@post/documents", {
body: {
content: url,
containerTags: [project],
entityContext,
metadata: { sm_source: "consumer" },
},
})
if (response.error) {
throw new Error(response.error?.message || "Failed to add link")
}
return response.data
},
onMutate: async ({ url, project }) => {
const previousQueries = await cancelAndSnapshotQueries(queryClient)
const now = new Date().toISOString()
const optimisticMemory: OptimisticMemory = {
id: `temp-${crypto.randomUUID()}`,
content: "",
url,
title: "Processing...",
description: "Extracting content...",
containerTags: [project],
createdAt: now,
updatedAt: now,
status: "queued",
type: "link",
metadata: {
processingStage: "queued",
processingMessage: "Added to processing queue",
},
memoryEntries: [],
isOptimistic: true,
}
queryClient.setQueriesData(
{ queryKey: ["documents-with-memories"] },
(old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
)
return { previousQueries }
},
onError: (error, _variables, context) => {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
toast.error("Failed to add link", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
analytics.documentAdded({ type: "link", project_id: variables.project })
toast.success("Link added successfully!", {
description: "Your link is being processed",
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
onClose?.()
},
})
const fileMutation = useMutation({
mutationFn: async ({
fileEntries,
title,
description,
project,
}: {
fileEntries: FileUploadEntry[]
title?: string
description?: string
project: string
}): Promise<FileUploadBatchResult> => {
const applyMeta = fileEntries.length === 1
const failures: { id: string; message: string }[] = []
const uploadOne = async (entry: FileUploadEntry) => {
const formData = new FormData()
formData.append("file", entry.file)
formData.append("containerTags", JSON.stringify([project]))
formData.append("entityContext", entityContext)
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
{
method: "POST",
body: formData,
credentials: "include",
},
)
if (!response.ok) {
let message = "Failed to upload file"
try {
const error = (await response.json()) as { error?: string }
if (error.error) message = error.error
} catch {
// ignore JSON parse errors
}
throw new Error(message)
}
const data = (await response.json()) as { id: string }
if (applyMeta && (title || description)) {
await $fetch(`@patch/documents/${data.id}`, {
body: {
metadata: {
...(title && { title }),
...(description && { description }),
sm_source: "consumer",
},
},
})
}
}
for (let i = 0; i < fileEntries.length; i += FILE_UPLOAD_CONCURRENCY) {
const slice = fileEntries.slice(i, i + FILE_UPLOAD_CONCURRENCY)
await Promise.all(
slice.map(async (entry) => {
try {
await uploadOne(entry)
} catch (e) {
failures.push({
id: entry.id,
message: e instanceof Error ? e.message : "Upload failed",
})
}
}),
)
}
const successCount = fileEntries.length - failures.length
if (successCount === 0) {
const firstFailure = failures[0]
throw new Error(
failures.length === 1 && firstFailure
? firstFailure.message
: `All ${failures.length} uploads failed`,
)
}
return { failures, successCount }
},
onMutate: async ({ fileEntries, title, description, project }) => {
if (fileEntries.length !== 1) {
return {
previousQueries: undefined as [unknown, unknown][] | undefined,
}
}
const previousQueries = await cancelAndSnapshotQueries(queryClient)
const entry = fileEntries[0]
if (!entry) {
return {
previousQueries: undefined as [unknown, unknown][] | undefined,
}
}
const now = new Date().toISOString()
const optimisticMemory: OptimisticMemory = {
id: `temp-file-${crypto.randomUUID()}`,
content: "",
url: null,
title: title || entry.file.name,
description: description || `Uploading ${entry.file.name}...`,
containerTags: [project],
createdAt: now,
updatedAt: now,
status: "processing",
type: "file",
metadata: {
fileName: entry.file.name,
fileSize: entry.file.size,
mimeType: entry.file.type,
},
memoryEntries: [],
}
queryClient.setQueriesData(
{ queryKey: ["documents-with-memories"] },
(old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
)
return { previousQueries }
},
onError: (error, variables, context) => {
if (variables.fileEntries.length === 1) {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
}
toast.error("Failed to upload file", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (data, variables) => {
for (let i = 0; i < data.successCount; i++) {
analytics.documentAdded({ type: "file", project_id: variables.project })
}
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
if (data.failures.length === 0) {
toast.success(
data.successCount === 1
? "File uploaded successfully!"
: `${data.successCount} files uploaded successfully!`,
{
description: "Your files are being processed",
},
)
onClose?.()
return
}
toast.warning("Some uploads failed", {
description: `${data.successCount} uploaded, ${data.failures.length} failed — fix or retry below`,
})
},
})
const updateMutation = useMutation({
mutationFn: async ({
documentId,
content,
}: {
documentId: string
content: string
}) => {
const response = await $fetch(`@patch/documents/${documentId}`, {
body: {
content,
metadata: { sm_source: "consumer" },
},
})
if (response.error) {
throw new Error(response.error?.message || "Failed to save document")
}
return response.data
},
onSuccess: (_data, variables) => {
analytics.documentEdited({ document_id: variables.documentId })
toast.success("Document saved successfully!")
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
},
onError: (error) => {
toast.error("Failed to save document", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
})
const deleteMutation = useMutation({
mutationFn: async ({ documentId }: { documentId: string }) => {
const response = await $fetch("@delete/documents/:id", {
params: { id: documentId },
})
if (response.error) {
throw new Error(response.error?.message || "Failed to delete document")
}
return response.data
},
onMutate: async ({ documentId }) => {
const previousQueries = await cancelAndSnapshotQueries(queryClient)
queryClient.setQueriesData(
{ queryKey: ["documents-with-memories"] },
(old) => removeDocumentFromQueryData(old, documentId),
)
return { previousQueries }
},
onError: (error, _variables, context) => {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
toast.error("Failed to delete document", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
analytics.documentDeleted({ document_id: variables.documentId })
toast.success("Document deleted successfully!")
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
onClose?.()
},
})
const bulkDeleteMutation = useMutation({
mutationFn: async ({ documentIds }: { documentIds: string[] }) => {
const response = await $fetch("@delete/documents/bulk", {
body: { ids: documentIds },
})
if (response.error) {
throw new Error(response.error?.message || "Failed to delete documents")
}
return response.data
},
onMutate: async ({ documentIds }) => {
const previousQueries = await cancelAndSnapshotQueries(queryClient)
const idSet = new Set(documentIds)
queryClient.setQueriesData(
{ queryKey: ["documents-with-memories"] },
(old) => removeDocumentsFromQueryData(old, idSet),
)
return { previousQueries }
},
onError: (error, _variables, context) => {
restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
toast.error("Failed to delete documents", {
description: error instanceof Error ? error.message : "Unknown error",
})
},
onSuccess: (_data, variables) => {
analytics.documentsBulkDeleted({ count: variables.documentIds.length })
toast.success(
`${variables.documentIds.length} document${variables.documentIds.length === 1 ? "" : "s"} deleted`,
)
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
},
})
return {
noteMutation,
linkMutation,
fileMutation,
updateMutation,
deleteMutation,
bulkDeleteMutation,
}
}