diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 9143120d..ba69e0b3 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -5250,6 +5250,7 @@ export type ListTasksData = { kind?: 'backup' | 'restore' | 'deleteSnapshots' | 'tagSnapshots' | 'doctor'; resourceType?: 'backup_schedule' | 'repository'; resourceId?: string; + operationKey?: string; }; url: '/api/v1/tasks'; }; @@ -5264,6 +5265,7 @@ export type ListTasksResponses = { status: 'queued' | 'running' | 'cancelling' | 'cancelled' | 'succeeded' | 'failed' | 'stale'; resourceType: 'backup_schedule' | 'repository'; resourceId: string; + operationKey: string | null; targetAgentId: string | null; input: { kind: 'backup'; @@ -5387,6 +5389,7 @@ export type StreamTasksEventsData = { kind?: 'backup' | 'restore' | 'deleteSnapshots' | 'tagSnapshots' | 'doctor'; resourceType?: 'backup_schedule' | 'repository'; resourceId?: string; + operationKey?: string; }; url: '/api/v1/tasks/events'; }; @@ -5401,6 +5404,7 @@ export type StreamTasksEventsResponses = { status: 'queued' | 'running' | 'cancelling' | 'cancelled' | 'succeeded' | 'failed' | 'stale'; resourceType: 'backup_schedule' | 'repository'; resourceId: string; + operationKey: string | null; targetAgentId: string | null; input: { kind: 'backup'; @@ -5518,6 +5522,7 @@ export type StreamTasksEventsResponses = { status: 'queued' | 'running' | 'cancelling' | 'cancelled' | 'succeeded' | 'failed' | 'stale'; resourceType: 'backup_schedule' | 'repository'; resourceId: string; + operationKey: string | null; targetAgentId: string | null; input: { kind: 'backup'; @@ -5653,6 +5658,7 @@ export type StreamTaskEventsResponses = { status: 'queued' | 'running' | 'cancelling' | 'cancelled' | 'succeeded' | 'failed' | 'stale'; resourceType: 'backup_schedule' | 'repository'; resourceId: string; + operationKey: string | null; targetAgentId: string | null; input: { kind: 'backup'; @@ -5788,6 +5794,7 @@ export type GetTaskResponses = { status: 'queued' | 'running' | 'cancelling' | 'cancelled' | 'succeeded' | 'failed' | 'stale'; resourceType: 'backup_schedule' | 'repository'; resourceId: string; + operationKey: string | null; targetAgentId: string | null; input: { kind: 'backup'; diff --git a/app/client/components/__test__/restore-form.test.tsx b/app/client/components/__test__/restore-form.test.tsx index 725f4ce4..12c19404 100644 --- a/app/client/components/__test__/restore-form.test.tsx +++ b/app/client/components/__test__/restore-form.test.tsx @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import type { ListTasksResponse } from "~/client/api-client"; import { restoreTasksOptions } from "~/client/modules/repositories/restore-tasks"; import { HttpResponse, http, server } from "~/test/msw/server"; import { cleanup, createTestQueryClient, render, screen, userEvent, waitFor, within } from "~/test/test-utils"; import { taskChangedEventName } from "~/schemas/task-events"; +import type { TaskOfKind } from "~/client/hooks/use-active-tasks"; +import type { Repository } from "~/client/lib/types"; import { fromAny } from "@total-typescript/shoehorn"; +import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot"; vi.mock("@tanstack/react-router", async (importOriginal) => { const actual = await importOriginal(); @@ -54,9 +56,9 @@ class MockEventSource { const originalEventSource = globalThis.EventSource; const repositoryId = "repo-1"; const snapshotId = "snap-1"; -type TaskResponse = ListTasksResponse[number]; -type RestoreProgress = Extract, { kind: "restore" }>["progress"]; -type RestoreResult = Extract, { kind: "restore" }>; +type TaskResponse = TaskOfKind<"restore">; +type RestoreProgress = NonNullable["progress"]; +type RestoreResult = NonNullable; const createRestoreTask = ( options: { @@ -79,6 +81,7 @@ const createRestoreTask = ( status, resourceType: "repository", resourceId: repositoryId, + operationKey: options.snapshotId ?? snapshotId, targetAgentId: null, input: { kind: "restore", @@ -119,13 +122,13 @@ const renderRestoreForm = (queryClient = createTestQueryClient()) => { ); }; -const renderRestoreFormWithPrefetchedTasks = async (tasks: ListTasksResponse) => { +const renderRestoreFormWithPrefetchedTask = async (task: TaskResponse) => { server.use( - http.get("/api/v1/tasks", () => HttpResponse.json(tasks)), + http.get("/api/v1/tasks", () => HttpResponse.json([task])), snapshotFilesHandler, ); const queryClient = createTestQueryClient(); - await queryClient.ensureQueryData(restoreTasksOptions(repositoryId)); + await queryClient.ensureQueryData(restoreTasksOptions(repositoryId, snapshotId)); renderRestoreForm(queryClient); await waitFor(() => { @@ -147,30 +150,23 @@ afterEach(() => { }); describe("RestoreForm", () => { - test("recovers the matching active restore from the prefetched task snapshot", async () => { - const matchingRestore = createRestoreTask({ updatedAt: 1711411200000 }); - const newerRestoreForAnotherSnapshot = createRestoreTask({ - id: "task-other-snapshot", - snapshotId: "snap-2", - updatedAt: 1711411201000, - }); - - const eventSource = await renderRestoreFormWithPrefetchedTasks([ - newerRestoreForAnotherSnapshot, - matchingRestore, - ]); + test("recovers the active restore from the prefetched exact filtered collection", async () => { + const taskStream = await renderRestoreFormWithPrefetchedTask(createRestoreTask()); const restoreButton = await screen.findByRole("button", { name: "Restoring..." }); expect(restoreButton.hasAttribute("disabled")).toBe(true); expect(screen.getByText("Restore in progress")).toBeTruthy(); - expect(eventSource?.url).toBe("/api/v1/tasks/events?kind=restore&resourceType=repository&resourceId=repo-1"); + expect(taskStream?.url).toBe( + "/api/v1/tasks/events?kind=restore&resourceType=repository&resourceId=repo-1&operationKey=snap-1", + ); + expect(MockEventSource.instances).toHaveLength(1); }); - test("renders canonical task progress without creating another event stream", async () => { + test("renders canonical task progress from the exact filtered stream", async () => { const activeRestore = createRestoreTask(); - const eventSource = await renderRestoreFormWithPrefetchedTasks([activeRestore]); + const taskStream = await renderRestoreFormWithPrefetchedTask(activeRestore); - eventSource?.emit( + taskStream?.emit( taskChangedEventName, createRestoreTask({ progress: { @@ -218,7 +214,16 @@ describe("RestoreForm", () => { expect(screen.getByRole("button", { name: "Restoring..." }).hasAttribute("disabled")).toBe(true); expect(screen.queryByRole("button", { name: "Restore All" })).toBeNull(); - MockEventSource.instances[0]?.emit(taskChangedEventName, createRestoreTask()); + await waitFor(() => { + expect( + MockEventSource.instances.some( + (eventSource) => eventSource.url === "/api/v1/tasks/task-restore/events", + ), + ).toBe(true); + }); + MockEventSource.instances + .find((eventSource) => eventSource.url === "/api/v1/tasks/task-restore/events") + ?.emit(taskChangedEventName, createRestoreTask()); expect(await screen.findByRole("button", { name: "Restoring..." })).toBeTruthy(); }); @@ -263,9 +268,9 @@ describe("RestoreForm", () => { "clears restoring state and shows $status terminal feedback", async ({ status, result, error, title, description }) => { const activeRestore = createRestoreTask(); - const eventSource = await renderRestoreFormWithPrefetchedTasks([activeRestore]); + const taskStream = await renderRestoreFormWithPrefetchedTask(activeRestore); - eventSource?.emit( + taskStream?.emit( taskChangedEventName, createRestoreTask({ status, @@ -285,6 +290,38 @@ describe("RestoreForm", () => { }, ); + test("resets restore lifecycle state when the repository snapshot scope changes", async () => { + server.use(snapshotFilesHandler); + const repository: Repository = fromAny({ shortId: repositoryId, name: "Repo 1" }); + const { rerender } = render( + , + ); + const firstStreamUrl = + "/api/v1/tasks/events?kind=restore&resourceType=repository&resourceId=repo-1&operationKey=snap-1"; + + await waitFor(() => { + expect(MockEventSource.instances.some(({ url }) => url === firstStreamUrl)).toBe(true); + }); + const firstStream = MockEventSource.instances.find(({ url }) => url === firstStreamUrl); + firstStream?.emit(taskChangedEventName, createRestoreTask()); + firstStream?.emit(taskChangedEventName, createRestoreTask({ status: "succeeded", updatedAt: 1711411200001 })); + + expect(await screen.findByText("Restore completed")).toBeTruthy(); + + rerender(); + + expect(screen.queryByText("Restore completed")).toBeNull(); + await waitFor(() => { + expect( + MockEventSource.instances.some( + ({ url }) => + url === + "/api/v1/tasks/events?kind=restore&resourceType=repository&resourceId=repo-1&operationKey=snap-2", + ), + ).toBe(true); + }); + }); + test("restores the selected ancestor folder path from a broader display root", async () => { let restoreRequestBody: unknown; diff --git a/app/client/hooks/__tests__/use-task-events.test.tsx b/app/client/hooks/__tests__/use-active-tasks.test.tsx similarity index 66% rename from app/client/hooks/__tests__/use-task-events.test.tsx rename to app/client/hooks/__tests__/use-active-tasks.test.tsx index f7f0d075..3999baa3 100644 --- a/app/client/hooks/__tests__/use-task-events.test.tsx +++ b/app/client/hooks/__tests__/use-active-tasks.test.tsx @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { ListTasksResponse } from "~/client/api-client"; import { cleanup, createTestQueryClient, render, waitFor } from "~/test/test-utils"; -import { taskChangedEventName } from "~/schemas/task-events"; +import { taskChangedEventName, tasksSnapshotEventName } from "~/schemas/task-events"; import type { TaskDto } from "~/schemas/tasks"; -import { taskEventsOptions, useTaskEvents } from "../use-task-events"; +import { taskEventsOptions, useActiveTasks, type TaskOfKind } from "../use-active-tasks"; class MockEventSource { static instances: MockEventSource[] = []; @@ -42,22 +42,25 @@ class MockEventSource { const originalEventSource = globalThis.EventSource; const filter = { - kind: "deleteSnapshots", + kind: "restore", resourceType: "repository", resourceId: "repo-1", + operationKey: "snap-1", } as const; const activeTask: TaskDto = { - id: "task-delete", - kind: "deleteSnapshots", + id: "task-restore", + kind: "restore", status: "running", resourceType: "repository", resourceId: "repo-1", + operationKey: "snap-1", targetAgentId: null, input: { - kind: "deleteSnapshots", + kind: "restore", repositoryId: "repo-1", - snapshotIds: ["snap-1"], + snapshotId: "snap-1", + target: "/restore", }, progress: null, result: null, @@ -72,17 +75,20 @@ const activeTask: TaskDto = { const finishedTask: TaskDto = { ...activeTask, status: "succeeded", - result: { kind: "deleteSnapshots", deletedSnapshotIds: ["snap-1"] }, + result: { + kind: "restore", + result: { message_type: "summary", files_restored: 1, files_skipped: 0 }, + }, updatedAt: 1711411201000, finishedAt: 1711411201000, }; -const TaskEventsConsumer = ({ onTaskFinished }: { onTaskFinished: (task: TaskDto) => void }) => { - useTaskEvents(filter, { onTaskFinished }); +const ActiveTasksConsumer = ({ onTaskFinished }: { onTaskFinished: (task: TaskOfKind<"restore">) => void }) => { + useActiveTasks(filter, { onTaskFinished }); return null; }; -describe("useTaskEvents", () => { +describe("useActiveTasks", () => { beforeEach(() => { MockEventSource.reset(); globalThis.EventSource = MockEventSource as unknown as typeof EventSource; @@ -94,20 +100,31 @@ describe("useTaskEvents", () => { MockEventSource.reset(); }); - test("ignores stale active events after a task has finished", async () => { + test("uses the exact operation URL and cache while reporting the finished restore once", async () => { const queryClient = createTestQueryClient(); const onTaskFinished = vi.fn(); - render(, { queryClient }); + render(, { queryClient }); await waitFor(() => { expect(MockEventSource.instances).toHaveLength(1); }); + expect(MockEventSource.instances[0]?.url).toBe( + "/api/v1/tasks/events?kind=restore&resourceType=repository&resourceId=repo-1&operationKey=snap-1", + ); + + MockEventSource.instances[0]?.emit(tasksSnapshotEventName, [activeTask]); + await waitFor(() => { + expect(queryClient.getQueryData(taskEventsOptions(filter).queryKey)).toEqual([ + activeTask, + ]); + }); MockEventSource.instances[0]?.emit(taskChangedEventName, finishedTask); await waitFor(() => { expect(onTaskFinished).toHaveBeenCalledTimes(1); }); + expect(onTaskFinished.mock.calls[0]?.[0].input.snapshotId).toBe("snap-1"); MockEventSource.instances[0]?.emit(taskChangedEventName, activeTask); diff --git a/app/client/hooks/use-task-events.ts b/app/client/hooks/use-active-tasks.ts similarity index 65% rename from app/client/hooks/use-task-events.ts rename to app/client/hooks/use-active-tasks.ts index 03015205..10e9eb12 100644 --- a/app/client/hooks/use-task-events.ts +++ b/app/client/hooks/use-active-tasks.ts @@ -1,14 +1,23 @@ import { useEffect, useMemo, useRef } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query"; import type { ListTasksData, ListTasksResponse } from "~/client/api-client"; import { getTaskOptions, listTasksOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { logger } from "~/client/lib/logger"; import { taskChangedEventName, tasksSnapshotEventName } from "~/schemas/task-events"; -import { activeTaskStatuses, type TaskDto } from "~/schemas/tasks"; +import { activeTaskStatuses, type TaskDto, type TaskKind } from "~/schemas/tasks"; export type TaskEventsQuery = NonNullable; -type UseTaskEventsOptions = { - onTaskFinished?: (task: TaskDto) => void; +export type TaskOfKind = TaskDto & { + kind: K; + input: Extract; + progress: Extract, { kind: K }> | null; + result: Extract, { kind: K }> | null; +}; + +type TaskForQuery = Q extends { kind: infer K extends TaskKind } ? TaskOfKind : TaskDto; + +type UseActiveTasksOptions = { + onTaskFinished?: (task: TaskForQuery) => void; }; const parseTaskEvent = (event: Event): TaskDto => { @@ -19,7 +28,7 @@ const parseTasksSnapshotEvent = (event: Event): TaskDto[] => { return JSON.parse((event as MessageEvent).data) as TaskDto[]; }; -const isActiveTask = (task: TaskDto) => { +export const isTaskActive = (task: Pick) => { return activeTaskStatuses.some((status) => status === task.status); }; @@ -28,6 +37,7 @@ const getTasksEventUrl = (query: TaskEventsQuery) => { if (query.kind) params.set("kind", query.kind); if (query.resourceType) params.set("resourceType", query.resourceType); if (query.resourceId) params.set("resourceId", query.resourceId); + if (query.operationKey) params.set("operationKey", query.operationKey); const queryString = params.toString(); if (!queryString) { @@ -60,13 +70,14 @@ export const taskEventsOptions = (query: TaskEventsQuery) => { return listTasksOptions({ query }); }; -export const useTaskEvents = (query: TaskEventsQuery, options: UseTaskEventsOptions = {}) => { +export const useActiveTasks = (query: Q, options: UseActiveTasksOptions = {}) => { const queryClient = useQueryClient(); const onTaskFinishedRef = useRef(options.onTaskFinished); const finishedTasksRef = useRef(new Map()); const queryKind = query.kind; const queryResourceType = query.resourceType; const queryResourceId = query.resourceId; + const queryOperationKey = query.operationKey; onTaskFinishedRef.current = options.onTaskFinished; const taskListOptions = useMemo(() => { @@ -74,16 +85,18 @@ export const useTaskEvents = (query: TaskEventsQuery, options: UseTaskEventsOpti kind: queryKind, resourceType: queryResourceType, resourceId: queryResourceId, + operationKey: queryOperationKey, }); - }, [queryKind, queryResourceId, queryResourceType]); + }, [queryKind, queryOperationKey, queryResourceId, queryResourceType]); const taskEventsUrl = useMemo(() => { return getTasksEventUrl({ kind: queryKind, resourceType: queryResourceType, resourceId: queryResourceId, + operationKey: queryOperationKey, }); - }, [queryKind, queryResourceId, queryResourceType]); + }, [queryKind, queryOperationKey, queryResourceId, queryResourceType]); const taskQueryKeyRef = useRef(taskListOptions.queryKey); taskQueryKeyRef.current = taskListOptions.queryKey; @@ -91,9 +104,24 @@ export const useTaskEvents = (query: TaskEventsQuery, options: UseTaskEventsOpti const tasks = useQuery({ ...taskListOptions, enabled: false }); useEffect(() => { - const eventSource = new EventSource(taskEventsUrl); + const finishTask = (task: TaskDto) => { + finishedTasksRef.current.set(task.id, task); + onTaskFinishedRef.current?.(task as TaskForQuery); + }; - eventSource.addEventListener(tasksSnapshotEventName, (event) => { + const reconcileMissingTask = async (missingTask: Pick) => { + const fetchedTask = (await queryClient.fetchQuery( + getTaskOptions({ path: { taskId: missingTask.id } }), + )) as TaskDto; + + if (isTaskActive(fetchedTask)) return; + if (fetchedTask.updatedAt < missingTask.updatedAt) return; + if (hasTaskFinished(finishedTasksRef.current, fetchedTask)) return; + + finishTask(fetchedTask); + }; + + const handleTasksSnapshot = (event: Event) => { const snapshot = parseTasksSnapshotEvent(event); const currentTasks = queryClient.getQueryData(taskQueryKeyRef.current) ?? []; @@ -106,32 +134,17 @@ export const useTaskEvents = (query: TaskEventsQuery, options: UseTaskEventsOpti ); for (const missingTask of missingTasks) { - void queryClient - .fetchQuery(getTaskOptions({ path: { taskId: missingTask.id } })) - .then((task) => { - const fetchedTask = task as TaskDto; - if ( - isActiveTask(fetchedTask) || - fetchedTask.updatedAt < missingTask.updatedAt || - hasTaskFinished(finishedTasksRef.current, fetchedTask) - ) { - return; - } - - finishedTasksRef.current.set(fetchedTask.id, fetchedTask); - onTaskFinishedRef.current?.(fetchedTask); - }) - .catch((error: unknown) => { - logger.error("[SSE] Failed to reconcile missing task:", error); - }); + void reconcileMissingTask(missingTask).catch((error: unknown) => { + logger.error("[SSE] Failed to reconcile missing task:", error); + }); } - }); + }; - eventSource.addEventListener(taskChangedEventName, (event) => { + const handleTaskChanged = (event: Event) => { const task = parseTaskEvent(event); const activeTasks = queryClient.getQueryData(taskQueryKeyRef.current) ?? []; - if (isActiveTask(task)) { + if (isTaskActive(task)) { if (hasTaskFinished(finishedTasksRef.current, task)) { return; } @@ -154,9 +167,12 @@ export const useTaskEvents = (query: TaskEventsQuery, options: UseTaskEventsOpti return; } - finishedTasksRef.current.set(task.id, task); - onTaskFinishedRef.current?.(task); - }); + finishTask(task); + }; + + const eventSource = new EventSource(taskEventsUrl); + eventSource.addEventListener(tasksSnapshotEventName, handleTasksSnapshot); + eventSource.addEventListener(taskChangedEventName, handleTaskChanged); eventSource.onerror = (error) => { logger.error("[SSE] Task stream connection error:", error); @@ -167,5 +183,5 @@ export const useTaskEvents = (query: TaskEventsQuery, options: UseTaskEventsOpti }; }, [queryClient, taskEventsUrl]); - return tasks; + return tasks as UseQueryResult[]>; }; diff --git a/app/client/hooks/use-task.ts b/app/client/hooks/use-task.ts new file mode 100644 index 00000000..330d5983 --- /dev/null +++ b/app/client/hooks/use-task.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import type { GetTaskResponse } from "~/client/api-client"; +import { logger } from "~/client/lib/logger"; +import { taskChangedEventName } from "~/schemas/task-events"; + +type StreamedTask = { + taskId: string; + task: T; +}; + +const parseTaskEvent = (event: Event): T => { + return JSON.parse((event as MessageEvent).data) as T; +}; + +export const useTask = (taskId: string | null | undefined) => { + const [streamedTask, setStreamedTask] = useState | null>(null); + + useEffect(() => { + if (!taskId) return; + + let isCurrent = true; + const eventSource = new EventSource(`/api/v1/tasks/${taskId}/events`); + const updateTask = (event: Event) => { + const nextTask = parseTaskEvent(event); + if (!isCurrent || nextTask.id !== taskId) return; + + setStreamedTask((current) => { + if (current?.taskId === taskId && nextTask.updatedAt < current.task.updatedAt) { + return current; + } + + return { taskId, task: nextTask }; + }); + }; + + eventSource.addEventListener(taskChangedEventName, updateTask); + eventSource.onerror = (error) => { + logger.error(`[SSE] Task ${taskId} connection error:`, error); + }; + + return () => { + isCurrent = false; + eventSource.close(); + }; + }, [taskId]); + + return { + task: streamedTask && streamedTask.taskId === taskId ? streamedTask.task : null, + }; +}; diff --git a/app/client/modules/backups/routes/__tests__/backup-details.test.tsx b/app/client/modules/backups/routes/__tests__/backup-details.test.tsx index 2e260a19..2a19db5e 100644 --- a/app/client/modules/backups/routes/__tests__/backup-details.test.tsx +++ b/app/client/modules/backups/routes/__tests__/backup-details.test.tsx @@ -138,6 +138,7 @@ const deleteSnapshotsTask = { status: "running", resourceType: "repository", resourceId: "repo-1", + operationKey: null, targetAgentId: null, input: { kind: "deleteSnapshots", diff --git a/app/client/modules/repositories/doctor-tasks.ts b/app/client/modules/repositories/doctor-tasks.ts index a643c1fa..da8f999d 100644 --- a/app/client/modules/repositories/doctor-tasks.ts +++ b/app/client/modules/repositories/doctor-tasks.ts @@ -1,6 +1,12 @@ import { toast } from "sonner"; -import { taskEventsOptions, useTaskEvents, type TaskEventsQuery } from "~/client/hooks/use-task-events"; -import type { TaskDto } from "~/schemas/tasks"; +import { + taskEventsOptions, + useActiveTasks, + type TaskEventsQuery, + type TaskOfKind, +} from "~/client/hooks/use-active-tasks"; + +type DoctorTask = TaskOfKind<"doctor">; const doctorTasksFilter = (repositoryId: string) => { return { @@ -10,7 +16,7 @@ const doctorTasksFilter = (repositoryId: string) => { } satisfies TaskEventsQuery; }; -const applyDoctorTaskFinished = (task: TaskDto) => { +const applyDoctorTaskFinished = (task: DoctorTask) => { if (task.status === "cancelled") { toast.info("Doctor cancelled"); return; @@ -23,7 +29,7 @@ const applyDoctorTaskFinished = (task: TaskDto) => { return; } - const result = task.result?.kind === "doctor" ? task.result : null; + const result = task.result; if (result?.repositoryStatus === "healthy") { toast.success("Doctor completed"); return; @@ -39,7 +45,7 @@ export const doctorTasksOptions = (repositoryId: string) => { }; export const useRepositoryDoctorTask = (repositoryId: string) => { - const doctorTasks = useTaskEvents(doctorTasksFilter(repositoryId), { + const doctorTasks = useActiveTasks(doctorTasksFilter(repositoryId), { onTaskFinished: applyDoctorTaskFinished, }); const activeDoctorTask = doctorTasks.data?.[0] ?? null; diff --git a/app/client/modules/repositories/restore-tasks.ts b/app/client/modules/repositories/restore-tasks.ts index bf8974cb..d20c2103 100644 --- a/app/client/modules/repositories/restore-tasks.ts +++ b/app/client/modules/repositories/restore-tasks.ts @@ -1,71 +1,50 @@ -import { useCallback, useEffect, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import type { ListTasksResponse } from "~/client/api-client"; -import { getTaskOptions } from "~/client/api-client/@tanstack/react-query.gen"; -import { taskEventsOptions, useTaskEvents, type TaskEventsQuery } from "~/client/hooks/use-task-events"; -import { activeTaskStatuses } from "~/schemas/tasks"; +import { useCallback, useState } from "react"; +import { + isTaskActive, + taskEventsOptions, + useActiveTasks, + type TaskEventsQuery, + type TaskOfKind, +} from "~/client/hooks/use-active-tasks"; +import { useTask } from "~/client/hooks/use-task"; -type TaskResponse = ListTasksResponse[number]; +type RestoreTask = TaskOfKind<"restore">; -const restoreTasksFilter = (repositoryId: string): TaskEventsQuery => ({ - kind: "restore", - resourceType: "repository", - resourceId: repositoryId, -}); - -const matchesRestore = (task: TaskResponse, snapshotId: string) => { - return task.kind === "restore" && task.input.kind === "restore" && task.input.snapshotId === snapshotId; +const restoreTasksFilter = (repositoryId: string, snapshotId: string) => { + return { + kind: "restore", + resourceType: "repository", + resourceId: repositoryId, + operationKey: snapshotId, + } satisfies TaskEventsQuery; }; -const isActive = (task: TaskResponse) => { - return activeTaskStatuses.some((status) => status === task.status); -}; - -export const restoreTasksOptions = (repositoryId: string) => { - return taskEventsOptions(restoreTasksFilter(repositoryId)); +export const restoreTasksOptions = (repositoryId: string, snapshotId: string) => { + return taskEventsOptions(restoreTasksFilter(repositoryId, snapshotId)); }; export const useRestoreTask = (repositoryId: string, snapshotId: string, startedTaskId?: string) => { - const [streamedFinishedTask, setStreamedFinishedTask] = useState(null); - const { data: streamedTasks } = useTaskEvents(restoreTasksFilter(repositoryId), { - onTaskFinished: (task) => { - if (matchesRestore(task, snapshotId)) { - setStreamedFinishedTask(task); - } - }, + const [retainedFinishedTask, setRetainedFinishedTask] = useState(null); + const filter = restoreTasksFilter(repositoryId, snapshotId); + const { data: activeRestoreTasks } = useActiveTasks(filter, { + onTaskFinished: setRetainedFinishedTask, }); - - const streamedActiveTask = streamedTasks?.find((task) => matchesRestore(task, snapshotId)) ?? null; - const taskFromStream = streamedActiveTask ?? streamedFinishedTask; - const needsFallback = startedTaskId !== undefined && taskFromStream?.id !== startedTaskId; - - const { data: fallbackTask } = useQuery({ - ...getTaskOptions({ path: { taskId: startedTaskId ?? "" } }), - enabled: needsFallback, - refetchInterval: ({ state }) => (state.data && !isActive(state.data) ? false : 1000), - }); - - const matchingFallbackTask = fallbackTask && matchesRestore(fallbackTask, snapshotId) ? fallbackTask : null; - const restoreTask = needsFallback ? matchingFallbackTask : taskFromStream; - const restoreIsActive = restoreTask ? isActive(restoreTask) : false; - - useEffect(() => { - if (streamedActiveTask) { - setStreamedFinishedTask(null); - } - }, [streamedActiveTask]); + const { task: exactStartedTask } = useTask(startedTaskId); + const activeRestoreTask = activeRestoreTasks?.[0] ?? null; + const restoreTask = exactStartedTask ?? activeRestoreTask ?? retainedFinishedTask; + const taskIsActive = restoreTask ? isTaskActive(restoreTask) : false; + const finishedRestoreTask = restoreTask && !taskIsActive ? restoreTask : null; const clearFinishedRestoreTask = useCallback(() => { - setStreamedFinishedTask(null); + setRetainedFinishedTask(null); }, []); - const restoreProgress = - restoreIsActive && restoreTask?.progress?.kind === "restore" ? restoreTask.progress.progress : null; + const restoreProgress = taskIsActive ? (restoreTask?.progress?.progress ?? null) : null; return { restoreProgress, - finishedRestoreTask: restoreIsActive ? null : restoreTask, + finishedRestoreTask, clearFinishedRestoreTask, - isRestoreRunning: restoreTask ? restoreIsActive : startedTaskId !== undefined, + isRestoreRunning: (startedTaskId !== undefined && exactStartedTask === null) || taskIsActive, }; }; diff --git a/app/client/modules/repositories/routes/restore-snapshot.tsx b/app/client/modules/repositories/routes/restore-snapshot.tsx index 4f2ec36f..aa6bdeb6 100644 --- a/app/client/modules/repositories/routes/restore-snapshot.tsx +++ b/app/client/modules/repositories/routes/restore-snapshot.tsx @@ -15,6 +15,7 @@ export function RestoreSnapshotPage(props: Props) { return ( ; -type DeleteSnapshotsTaskResult = Extract, { kind: "deleteSnapshots" }>; -type DeleteSnapshotsTask = TaskDto & { - input: DeleteSnapshotsTaskInput; - result: DeleteSnapshotsTaskResult | null; -}; -type DeleteSnapshotTasksFilter = NonNullable; +type DeleteSnapshotsTask = TaskOfKind<"deleteSnapshots">; const emptyDeletingSnapshotIds = new Set(); -const deleteSnapshotTasksFilter = (repositoryId: string): DeleteSnapshotTasksFilter => { +const deleteSnapshotTasksFilter = (repositoryId: string) => { return { kind: "deleteSnapshots", resourceType: "repository", resourceId: repositoryId, - }; + } satisfies TaskEventsQuery; }; const removeSnapshotsFromCache = (queryClient: QueryClient, repositoryId: string, snapshotIds: string[]) => { @@ -62,8 +60,8 @@ export const deleteSnapshotTasksOptions = (repositoryId: string) => { export const useDeletingSnapshots = (repositoryId: string) => { const queryClient = useQueryClient(); const filter = deleteSnapshotTasksFilter(repositoryId); - const deleteTasks = useTaskEvents(filter, { - onTaskFinished: (task) => applyDeleteSnapshotsTaskFinished(queryClient, task as DeleteSnapshotsTask), + const deleteTasks = useActiveTasks(filter, { + onTaskFinished: (task) => applyDeleteSnapshotsTaskFinished(queryClient, task), }); const deletingSnapshotIds = useMemo(() => { @@ -72,9 +70,7 @@ export const useDeletingSnapshots = (repositoryId: string) => { } const snapshotIds = new Set(); - const deleteSnapshotTasks = deleteTasks.data as DeleteSnapshotsTask[]; - - for (const task of deleteSnapshotTasks) { + for (const task of deleteTasks.data) { for (const snapshotId of task.input.snapshotIds) { snapshotIds.add(snapshotId); } diff --git a/app/client/modules/repositories/snapshots/tag-tasks.ts b/app/client/modules/repositories/snapshots/tag-tasks.ts index 3903942d..96bb26d2 100644 --- a/app/client/modules/repositories/snapshots/tag-tasks.ts +++ b/app/client/modules/repositories/snapshots/tag-tasks.ts @@ -2,28 +2,26 @@ import { useMemo } from "react"; import type { QueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import type { ListSnapshotsResponse, ListTasksData } from "~/client/api-client"; +import type { ListSnapshotsResponse } from "~/client/api-client"; import { listSnapshotsQueryKey } from "~/client/api-client/@tanstack/react-query.gen"; -import { taskEventsOptions, useTaskEvents } from "~/client/hooks/use-task-events"; +import { + taskEventsOptions, + useActiveTasks, + type TaskEventsQuery, + type TaskOfKind, +} from "~/client/hooks/use-active-tasks"; import type { BackupSchedule } from "~/client/lib/types"; -import type { TaskDto } from "~/schemas/tasks"; -type TagSnapshotsTaskInput = Extract; -type TagSnapshotsTaskResult = Extract, { kind: "tagSnapshots" }>; -type TagSnapshotsTask = TaskDto & { - input: TagSnapshotsTaskInput; - result: TagSnapshotsTaskResult | null; -}; -type TagSnapshotTasksFilter = NonNullable; +type TagSnapshotsTask = TaskOfKind<"tagSnapshots">; const emptyTaggingSnapshotIds = new Set(); -const tagSnapshotTasksFilter = (repositoryId: string): TagSnapshotTasksFilter => { +const tagSnapshotTasksFilter = (repositoryId: string) => { return { kind: "tagSnapshots", resourceType: "repository", resourceId: repositoryId, - }; + } satisfies TaskEventsQuery; }; const applyTaskTagsToSnapshot = (snapshot: ListSnapshotsResponse[number], task: TagSnapshotsTask) => { @@ -131,8 +129,8 @@ export const tagSnapshotTasksOptions = (repositoryId: string) => { export const useTaggingSnapshots = (repositoryId: string, backups: BackupSchedule[]) => { const queryClient = useQueryClient(); const filter = tagSnapshotTasksFilter(repositoryId); - const tagTasks = useTaskEvents(filter, { - onTaskFinished: (task) => applyTagSnapshotsTaskFinished(queryClient, task as TagSnapshotsTask, backups), + const tagTasks = useActiveTasks(filter, { + onTaskFinished: (task) => applyTagSnapshotsTaskFinished(queryClient, task, backups), }); const taggingSnapshotIds = useMemo(() => { @@ -141,9 +139,7 @@ export const useTaggingSnapshots = (repositoryId: string, backups: BackupSchedul } const snapshotIds = new Set(); - const tagSnapshotTasks = tagTasks.data as TagSnapshotsTask[]; - - for (const task of tagSnapshotTasks) { + for (const task of tagTasks.data) { for (const snapshotId of task.input.snapshotIds) { snapshotIds.add(snapshotId); } diff --git a/app/drizzle/20260710122522_brown_living_lightning/migration.sql b/app/drizzle/20260710122522_brown_living_lightning/migration.sql new file mode 100644 index 00000000..dea53271 --- /dev/null +++ b/app/drizzle/20260710122522_brown_living_lightning/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `tasks` ADD `operation_key` text; \ No newline at end of file diff --git a/app/drizzle/20260710122522_brown_living_lightning/snapshot.json b/app/drizzle/20260710122522_brown_living_lightning/snapshot.json new file mode 100644 index 00000000..7ba878e3 --- /dev/null +++ b/app/drizzle/20260710122522_brown_living_lightning/snapshot.json @@ -0,0 +1,3539 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "c0244cec-3d8d-4726-9983-6fc37dccf16e", + "prevIds": ["8c80455d-d606-46e7-a31e-a2b87a4bf0e9"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "agents_table", + "entityType": "tables" + }, + { + "name": "apikey", + "entityType": "tables" + }, + { + "name": "app_metadata", + "entityType": "tables" + }, + { + "name": "backup_schedule_mirrors_table", + "entityType": "tables" + }, + { + "name": "backup_schedule_notifications_table", + "entityType": "tables" + }, + { + "name": "backup_schedules_table", + "entityType": "tables" + }, + { + "name": "invitation", + "entityType": "tables" + }, + { + "name": "member", + "entityType": "tables" + }, + { + "name": "notification_destinations_table", + "entityType": "tables" + }, + { + "name": "organization", + "entityType": "tables" + }, + { + "name": "passkey", + "entityType": "tables" + }, + { + "name": "repositories_table", + "entityType": "tables" + }, + { + "name": "repository_lock_waiters", + "entityType": "tables" + }, + { + "name": "repository_locks", + "entityType": "tables" + }, + { + "name": "sessions_table", + "entityType": "tables" + }, + { + "name": "sso_provider", + "entityType": "tables" + }, + { + "name": "tasks", + "entityType": "tables" + }, + { + "name": "two_factor", + "entityType": "tables" + }, + { + "name": "users_table", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "name": "volumes_table", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'offline'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'{}'", + "generated": null, + "name": "capabilities", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_seen_at", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_ready_at", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'default'", + "generated": null, + "name": "config_id", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "start", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reference_id", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prefix", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refill_interval", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refill_amount", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_refill_at", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "rate_limit_enabled", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rate_limit_time_window", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rate_limit_max", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "request_count", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "remaining", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_request", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permissions", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "schedule_id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_at", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_status", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_error", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "schedule_id", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "destination_id", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "notify_on_start", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "notify_on_success", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "notify_on_warning", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "notify_on_failure", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "volume_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "cron_expression", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "retention_policy", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "exclude_patterns", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "exclude_if_present", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "include_paths", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "include_patterns", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_status", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_error", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "next_backup_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "one_file_system", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "custom_restic_params", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backup_webhooks", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "sort_order", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "failure_retry_count", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "2", + "generated": null, + "name": "max_retries", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "900000", + "generated": null, + "name": "retry_delay", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'pending'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "inviter_id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'member'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "member" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "member" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'unknown'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_checked", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logo", + "entityType": "columns", + "table": "organization" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "credential_id", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "counter", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "device_type", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backed_up", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "transports", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aaguid", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provisioning_id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'auto'", + "generated": null, + "name": "compression_mode", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'unknown'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_checked", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "doctor_result", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stats", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stats_updated_at", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "upload_limit_enabled", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "1", + "generated": null, + "name": "upload_limit_value", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Mbps'", + "generated": null, + "name": "upload_limit_unit", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "download_limit_enabled", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "1", + "generated": null, + "name": "download_limit_value", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Mbps'", + "generated": null, + "name": "download_limit_unit", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "operation", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "requested_at", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "heartbeat_at", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "operation", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "acquired_at", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "heartbeat_at", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ip_address", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_agent", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "impersonated_by", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_organization_id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'browser-session'", + "generated": null, + "name": "auth_source", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "issuer", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "domain", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "auto_link_matching_emails", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "oidc_config", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "saml_config", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource_type", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource_id", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "operation_key", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_agent_id", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "input", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "progress", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "cancellation_requested", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "started_at", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finished_at", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backup_codes", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "verified", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "failed_verification_count", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "locked_until", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "username", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password_hash", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "has_downloaded_restic_password", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'MM/DD/YYYY'", + "generated": null, + "name": "date_format", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'12h'", + "generated": null, + "name": "time_format", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "email_verified", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "display_username", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "two_factor_enabled", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'user'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "banned", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ban_reason", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ban_expires", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provisioning_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'unmounted'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "last_health_check", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "auto_remount", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'local'", + "generated": null, + "name": "agent_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "account_user_id_users_table_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_agents_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "agents_table" + }, + { + "columns": ["reference_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_apikey_reference_id_users_table_id_fk", + "entityType": "fks", + "table": "apikey" + }, + { + "columns": ["schedule_id"], + "tableTo": "backup_schedules_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["repository_id"], + "tableTo": "repositories_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["schedule_id"], + "tableTo": "backup_schedules_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["destination_id"], + "tableTo": "notification_destinations_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["volume_id"], + "tableTo": "volumes_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["repository_id"], + "tableTo": "repositories_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "invitation_organization_id_organization_id_fk", + "entityType": "fks", + "table": "invitation" + }, + { + "columns": ["inviter_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "invitation_inviter_id_users_table_id_fk", + "entityType": "fks", + "table": "invitation" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "member_organization_id_organization_id_fk", + "entityType": "fks", + "table": "member" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "member_user_id_users_table_id_fk", + "entityType": "fks", + "table": "member" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "notification_destinations_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "notification_destinations_table" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_passkey_user_id_users_table_id_fk", + "entityType": "fks", + "table": "passkey" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "repositories_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "repositories_table" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "sessions_table_user_id_users_table_id_fk", + "entityType": "fks", + "table": "sessions_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_sso_provider_organization_id_organization_id_fk", + "entityType": "fks", + "table": "sso_provider" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_sso_provider_user_id_users_table_id_fk", + "entityType": "fks", + "table": "sso_provider" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_tasks_organization_id_organization_id_fk", + "entityType": "fks", + "table": "tasks" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "two_factor_user_id_users_table_id_fk", + "entityType": "fks", + "table": "two_factor" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "volumes_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "volumes_table" + }, + { + "columns": ["schedule_id", "destination_id"], + "nameExplicit": false, + "name": "backup_schedule_notifications_table_schedule_id_destination_id_pk", + "entityType": "pks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "agents_table_pk", + "table": "agents_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "apikey_pk", + "table": "apikey", + "entityType": "pks" + }, + { + "columns": ["key"], + "nameExplicit": false, + "name": "app_metadata_pk", + "table": "app_metadata", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_pk", + "table": "backup_schedule_mirrors_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "backup_schedules_table_pk", + "table": "backup_schedules_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "invitation_pk", + "table": "invitation", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "member_pk", + "table": "member", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "notification_destinations_table_pk", + "table": "notification_destinations_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "organization_pk", + "table": "organization", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "passkey_pk", + "table": "passkey", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "repositories_table_pk", + "table": "repositories_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "repository_lock_waiters_pk", + "table": "repository_lock_waiters", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "repository_locks_pk", + "table": "repository_locks", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_table_pk", + "table": "sessions_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sso_provider_pk", + "table": "sso_provider", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "tasks_pk", + "table": "tasks", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "two_factor_pk", + "table": "two_factor", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "users_table_pk", + "table": "users_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "volumes_table_pk", + "table": "volumes_table", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "account_userId_idx", + "entityType": "indexes", + "table": "account" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "agents_table_organization_id_idx", + "entityType": "indexes", + "table": "agents_table" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "agents_table_status_idx", + "entityType": "indexes", + "table": "agents_table" + }, + { + "columns": [ + { + "value": "config_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "apikey_configId_idx", + "entityType": "indexes", + "table": "apikey" + }, + { + "columns": [ + { + "value": "reference_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "apikey_referenceId_idx", + "entityType": "indexes", + "table": "apikey" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "apikey_key_unique", + "entityType": "indexes", + "table": "apikey" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "invitation_organizationId_idx", + "entityType": "indexes", + "table": "invitation" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "invitation_email_idx", + "entityType": "indexes", + "table": "invitation" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "member_organizationId_idx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "member_userId_idx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "member_org_user_uidx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "organization_slug_uidx", + "entityType": "indexes", + "table": "organization" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "passkey_userId_idx", + "entityType": "indexes", + "table": "passkey" + }, + { + "columns": [ + { + "value": "credential_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "passkey_credentialID_idx", + "entityType": "indexes", + "table": "passkey" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "provisioning_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "repositories_table_org_provisioning_id_uidx", + "entityType": "indexes", + "table": "repositories_table" + }, + { + "columns": [ + { + "value": "repository_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_lock_waiters_repository_id_idx", + "entityType": "indexes", + "table": "repository_lock_waiters" + }, + { + "columns": [ + { + "value": "expires_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_lock_waiters_expires_at_idx", + "entityType": "indexes", + "table": "repository_lock_waiters" + }, + { + "columns": [ + { + "value": "owner_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_lock_waiters_owner_id_idx", + "entityType": "indexes", + "table": "repository_lock_waiters" + }, + { + "columns": [ + { + "value": "repository_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_locks_repository_id_idx", + "entityType": "indexes", + "table": "repository_locks" + }, + { + "columns": [ + { + "value": "expires_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_locks_expires_at_idx", + "entityType": "indexes", + "table": "repository_locks" + }, + { + "columns": [ + { + "value": "owner_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_locks_owner_id_idx", + "entityType": "indexes", + "table": "repository_locks" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "sessionsTable_userId_idx", + "entityType": "indexes", + "table": "sessions_table" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "kind", + "isExpression": false + }, + { + "value": "resource_type", + "isExpression": false + }, + { + "value": "resource_id", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "tasks_org_kind_resource_status_idx", + "entityType": "indexes", + "table": "tasks" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + }, + { + "value": "updated_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "tasks_org_status_updated_at_idx", + "entityType": "indexes", + "table": "tasks" + }, + { + "columns": [ + { + "value": "secret", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "twoFactor_secret_idx", + "entityType": "indexes", + "table": "two_factor" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "twoFactor_userId_idx", + "entityType": "indexes", + "table": "two_factor" + }, + { + "columns": [ + { + "value": "identifier", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "verification_identifier_idx", + "entityType": "indexes", + "table": "verification" + }, + { + "columns": [ + { + "value": "agent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "volumes_table_agent_id_idx", + "entityType": "indexes", + "table": "volumes_table" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "provisioning_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "volumes_table_org_provisioning_id_uidx", + "entityType": "indexes", + "table": "volumes_table" + }, + { + "columns": ["schedule_id", "repository_id"], + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique", + "entityType": "uniques", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["name", "organization_id"], + "nameExplicit": false, + "name": "volumes_table_name_organization_id_unique", + "entityType": "uniques", + "table": "volumes_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "backup_schedules_table_short_id_unique", + "entityType": "uniques", + "table": "backup_schedules_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "repositories_table_short_id_unique", + "entityType": "uniques", + "table": "repositories_table" + }, + { + "columns": ["token"], + "nameExplicit": false, + "name": "sessions_table_token_unique", + "entityType": "uniques", + "table": "sessions_table" + }, + { + "columns": ["provider_id"], + "nameExplicit": false, + "name": "sso_provider_provider_id_unique", + "entityType": "uniques", + "table": "sso_provider" + }, + { + "columns": ["username"], + "nameExplicit": false, + "name": "users_table_username_unique", + "entityType": "uniques", + "table": "users_table" + }, + { + "columns": ["email"], + "nameExplicit": false, + "name": "users_table_email_unique", + "entityType": "uniques", + "table": "users_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "volumes_table_short_id_unique", + "entityType": "uniques", + "table": "volumes_table" + } + ], + "renames": [] +} diff --git a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx index 9de894ba..1929fe36 100644 --- a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx +++ b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx @@ -16,7 +16,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId throw new Response("Not Found", { status: 404 }); } - const restoreTaskOptions = restoreTasksOptions(schedule.data.repository.shortId); + const restoreTaskOptions = restoreTasksOptions(schedule.data.repository.shortId, params.snapshotId); const [snapshot, repository] = await Promise.all([ context.queryClient.ensureQueryData({ ...getSnapshotDetailsOptions({ diff --git a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx index ed080166..4f284b13 100644 --- a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx +++ b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx @@ -10,7 +10,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s component: RouteComponent, errorComponent: (e) =>
{e.error.message}
, loader: async ({ params, context }) => { - const restoreTaskOptions = restoreTasksOptions(params.repositoryId); + const restoreTaskOptions = restoreTasksOptions(params.repositoryId, params.snapshotId); const [snapshot, repository] = await Promise.all([ context.queryClient.ensureQueryData({ ...getSnapshotDetailsOptions({ path: { shortId: params.repositoryId, snapshotId: params.snapshotId } }), diff --git a/app/schemas/tasks.ts b/app/schemas/tasks.ts index 91e5197e..1564ffe7 100644 --- a/app/schemas/tasks.ts +++ b/app/schemas/tasks.ts @@ -93,6 +93,7 @@ const taskShape = { status: taskStatusSchema, resourceType: taskResourceTypeSchema, resourceId: z.string(), + operationKey: z.string().nullable(), targetAgentId: z.string().nullable(), input: taskInputSchema, progress: taskProgressSchema.nullable(), diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 032698dd..d1164322 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -368,6 +368,7 @@ export const tasksTable = sqliteTable( status: text("status").notNull(), resourceType: text("resource_type").notNull(), resourceId: text("resource_id").notNull(), + operationKey: text("operation_key"), targetAgentId: text("target_agent_id"), input: text("input", { mode: "json" }).$type().notNull(), progress: text("progress", { mode: "json" }).$type(), diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index e52ba174..bb10c538 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -38,15 +38,11 @@ import type { RestoreExecutionProgress, RestoreExecutionResult } from "../agents import { agentsService } from "../agents/agents.service"; import { LOCAL_AGENT_ID } from "../agents/constants"; import { taskStore } from "../tasks/tasks.store"; -import type { ParsedTask, TaskInput } from "~/schemas/tasks"; import { Effect } from "effect"; const lsLimiters = new Map(); const RESTORE_TASK_RESOURCE_TYPE = "repository"; -type RestoreTaskInput = Extract; -type RestoreTask = ParsedTask & { kind: "restore"; input: RestoreTaskInput }; - const getBlockedRestoreTargets = () => { return [ ...RESTORE_BLOCKED_ROOTS, @@ -69,9 +65,6 @@ const assertAllowedControllerLocalRestoreTarget = (target: string) => { } }; -const isRestoreTask = (task: ParsedTask): task is RestoreTask => - task.kind === "restore" && task.input.kind === "restore"; - const updateActiveRestoreTask = (restoreId: string, eventName: string, update: () => void) => { try { update(); @@ -89,21 +82,14 @@ const getLsLimiter = (repositoryId: string) => { return limiter; }; -const findActiveRestoreTask = ( - organizationId: string, - repositoryShortId: string, - snapshotId: string, -): RestoreTask | null => { - return ( - taskStore - .listActiveByResource({ - organizationId, - kind: "restore", - resourceType: RESTORE_TASK_RESOURCE_TYPE, - resourceId: repositoryShortId, - }) - .find((task): task is RestoreTask => isRestoreTask(task) && task.input.snapshotId === snapshotId) ?? null - ); +const findActiveRestoreTask = (organizationId: string, repositoryShortId: string, snapshotId: string) => { + return taskStore.findActiveByResource({ + organizationId, + kind: "restore", + resourceType: RESTORE_TASK_RESOURCE_TYPE, + resourceId: repositoryShortId, + operationKey: snapshotId, + }); }; const findActiveDoctorTask = (organizationId: string, repositoryShortId: string) => { @@ -484,6 +470,7 @@ const restoreSnapshot = async ( organizationId, resourceType: RESTORE_TASK_RESOURCE_TYPE, resourceId: repository.shortId, + operationKey: snapshotId, targetAgentId: useControllerLocalRestoreFallback ? null : executionAgentId, input: { kind: "restore", repositoryId: repository.shortId, snapshotId, target }, }); diff --git a/app/server/modules/tasks/__tests__/tasks.controller.test.ts b/app/server/modules/tasks/__tests__/tasks.controller.test.ts index bdc136d7..5ac8721d 100644 --- a/app/server/modules/tasks/__tests__/tasks.controller.test.ts +++ b/app/server/modules/tasks/__tests__/tasks.controller.test.ts @@ -28,15 +28,16 @@ const createTask = ( }); }; -const createRestoreTask = (organizationId: string, repositoryId = "repo-short") => { +const createRestoreTask = (organizationId: string, repositoryId = "repo-short", snapshotId = "snapshot-1") => { return taskStore.create({ organizationId, resourceType: "repository", resourceId: repositoryId, + operationKey: snapshotId, input: { kind: "restore", repositoryId, - snapshotId: "snapshot-1", + snapshotId, target: "/tmp/restore", }, }); @@ -231,4 +232,55 @@ describe("tasksController", () => { const byKindAndResourceBody = await byKindAndResource.json(); expect(byKindAndResourceBody.map((task: { id: string }) => task.id).sort()).toEqual([repoTask.id]); }); + + test("lists only the task for the exact operation key", async () => { + const session = await createTestSession(); + const matchingTask = createRestoreTask(session.organizationId, "repo-short", "snapshot-1"); + createRestoreTask(session.organizationId, "repo-short", "snapshot-2"); + + const res = await app.request( + "/api/v1/tasks?kind=restore&resourceType=repository&resourceId=repo-short&operationKey=snapshot-1", + { headers: session.headers }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.map((task: { id: string }) => task.id)).toEqual([matchingTask.id]); + }); + + test("streams task snapshots and updates only for the exact operation key", async () => { + const session = await createTestSession(); + const matchingTask = createRestoreTask(session.organizationId, "repo-short", "snapshot-1"); + const otherSnapshotTask = createRestoreTask(session.organizationId, "repo-short", "snapshot-2"); + + const res = await app.request( + "/api/v1/tasks/events?kind=restore&resourceType=repository&resourceId=repo-short&operationKey=snapshot-1", + { headers: session.headers }, + ); + + expect(res.status).toBe(200); + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + try { + text = await readReaderUntil(reader, decoder, text, matchingTask.id); + + expect(text).toContain(`event: ${tasksSnapshotEventName}`); + expect(text).toContain(matchingTask.id); + expect(text).not.toContain(otherSnapshotTask.id); + + taskStore.fail(otherSnapshotTask.id, "other snapshot failed"); + taskStore.fail(matchingTask.id, "matching snapshot failed"); + text = await readReaderUntil(reader, decoder, text, "matching snapshot failed"); + + expect(text).toContain(`event: ${taskChangedEventName}`); + expect(text).toContain("matching snapshot failed"); + expect(text).not.toContain("other snapshot failed"); + } finally { + void reader.cancel(); + reader.releaseLock(); + } + }); }); diff --git a/app/server/modules/tasks/tasks.controller.ts b/app/server/modules/tasks/tasks.controller.ts index 09c65cf0..2021a8e2 100644 --- a/app/server/modules/tasks/tasks.controller.ts +++ b/app/server/modules/tasks/tasks.controller.ts @@ -16,39 +16,19 @@ import { import { toTaskDto } from "./tasks.presenter"; import { taskStore } from "./tasks.store"; -type TaskFilter = NonNullable[0]>; - -const taskMatchesFilter = (task: ReturnType[number], filter: TaskFilter) => { - if (filter.organizationId && task.organizationId !== filter.organizationId) { - return false; - } - - if (filter.kind && task.kind !== filter.kind) { - return false; - } - - if (filter.resourceType && task.resourceType !== filter.resourceType) { - return false; - } - - if (filter.resourceId && task.resourceId !== filter.resourceId) { - return false; - } - - return true; -}; - export const tasksController = new Hono() .use(requireAuth) .get("/", validator("query", listTasksQuery), listTasksDto, async (c) => { const organizationId = c.get("organizationId"); const query = c.req.valid("query"); - const tasks = taskStore.listActive({ + const filter = { organizationId, kind: query.kind, resourceType: query.resourceType, resourceId: query.resourceId, - }); + operationKey: query.operationKey, + }; + const tasks = taskStore.listActive(filter); const response = tasks.map(toTaskDto); return c.json(response, 200); @@ -61,6 +41,7 @@ export const tasksController = new Hono() kind: query.kind, resourceType: query.resourceType, resourceId: query.resourceId, + operationKey: query.operationKey, }; return streamEvents(c, { @@ -76,11 +57,7 @@ export const tasksController = new Hono() }); }, subscribe: (_eventName, handler) => { - return taskStore.subscribeToAllChanges((changedTask) => { - if (!taskMatchesFilter(changedTask, filter)) { - return; - } - + return taskStore.subscribeToAllChanges(filter, (changedTask) => { const taskData = toTaskDto(changedTask); void handler(taskData); }); diff --git a/app/server/modules/tasks/tasks.dto.ts b/app/server/modules/tasks/tasks.dto.ts index d56576ea..3e5cbf99 100644 --- a/app/server/modules/tasks/tasks.dto.ts +++ b/app/server/modules/tasks/tasks.dto.ts @@ -7,6 +7,7 @@ export const listTasksQuery = z kind: taskKindSchema.optional(), resourceType: taskResourceTypeSchema.optional(), resourceId: z.string().optional(), + operationKey: z.string().optional(), }) .superRefine((query, ctx) => { const hasResourceType = Boolean(query.resourceType); diff --git a/app/server/modules/tasks/tasks.store.ts b/app/server/modules/tasks/tasks.store.ts index 0b404f68..cdb3a6e6 100644 --- a/app/server/modules/tasks/tasks.store.ts +++ b/app/server/modules/tasks/tasks.store.ts @@ -20,6 +20,7 @@ type TaskResource = { kind: TaskKind; resourceType: TaskResourceType; resourceId: string; + operationKey?: string; }; type CreateTaskParams = { @@ -27,6 +28,7 @@ type CreateTaskParams = { organizationId: string; resourceType: TaskResourceType; resourceId: string; + operationKey?: string | null; targetAgentId?: string | null; input: TaskInput; }; @@ -89,6 +91,16 @@ const subscribeToTaskChanges = (taskId: string, listener: TaskChangeListener) => }; }; +const taskMatchesFilter = (task: ParsedTask, filter: Partial) => { + if (filter.organizationId && task.organizationId !== filter.organizationId) return false; + if (filter.kind && task.kind !== filter.kind) return false; + if (filter.resourceType && task.resourceType !== filter.resourceType) return false; + if (filter.resourceId && task.resourceId !== filter.resourceId) return false; + if (filter.operationKey && task.operationKey !== filter.operationKey) return false; + + return true; +}; + const activeStatusCondition = () => inArray(tasksTable.status, activeTaskStatuses); const byIdCondition = (id: string) => eq(tasksTable.id, id); @@ -100,6 +112,7 @@ const buildActiveConditions = (params: Partial = {}) => { if (params.kind) conditions.push(eq(tasksTable.kind, params.kind)); if (params.resourceType) conditions.push(eq(tasksTable.resourceType, params.resourceType)); if (params.resourceId) conditions.push(eq(tasksTable.resourceId, params.resourceId)); + if (params.operationKey) conditions.push(eq(tasksTable.operationKey, params.operationKey)); return conditions; }; @@ -137,6 +150,7 @@ export const taskStore = { status: "queued", resourceType: params.resourceType, resourceId: params.resourceId, + operationKey: params.operationKey ?? null, targetAgentId: params.targetAgentId ?? null, input, progress: null, @@ -268,10 +282,6 @@ export const taskStore = { return row ? parseTask(row) : null; }, - listActiveByResource: (params: TaskResource): ParsedTask[] => { - return listActiveTasks(params); - }, - listActive: (params: ListActiveTasksParams = {}): ParsedTask[] => { return listActiveTasks(params); }, @@ -280,8 +290,12 @@ export const taskStore = { return subscribeToTaskChanges(taskId, listener); }, - subscribeToAllChanges: (listener: TaskChangeListener) => { - return subscribeToAllTaskChanges(listener); + subscribeToAllChanges: (filter: ListActiveTasksParams, listener: TaskChangeListener) => { + return subscribeToAllTaskChanges((task) => { + if (taskMatchesFilter(task, filter)) { + listener(task); + } + }); }, findById: (params: FindTaskParams): ParsedTask | null => {