diff --git a/apps/website/app/api/supabase/sync-task/[fn]/[target]/[worker]/route.ts b/apps/website/app/api/supabase/sync-task/[fn]/[target]/[worker]/route.ts new file mode 100644 index 000000000..08448c19f --- /dev/null +++ b/apps/website/app/api/supabase/sync-task/[fn]/[target]/[worker]/route.ts @@ -0,0 +1,55 @@ +import { NextResponse, NextRequest } from "next/server"; +import { PostgrestSingleResponse } from "@supabase/supabase-js"; +import { Database, Constants } from "@repo/database/types.gen.ts"; +import { createClient } from "~/utils/supabase/server"; +import { + createApiResponse, + asPostgrestFailure, + handleRouteError, +} from "~/utils/supabase/apiUtils"; + +type ApiParams = Promise<{ target: string; fn: string; worker: string }>; +export type SegmentDataType = { params: ApiParams }; + +// POST the task status to the /supabase/sync-task/{function_name}/{target}/{worker} endpoint +export const POST = async ( + request: NextRequest, + segmentData: SegmentDataType, +): Promise => { + try { + const { target, fn, worker } = await segmentData.params; + const targetN = Number.parseInt(target); + if (isNaN(targetN)) { + return createApiResponse( + request, + asPostgrestFailure(`${target} is not a number`, "type"), + ); + } + const infoS: string = await request.json(); + if ( + !(Constants.public.Enums.task_status as readonly string[]).includes(infoS) + ) { + return createApiResponse( + request, + asPostgrestFailure(`${infoS} is not a task status`, "type"), + ); + } + const info = infoS as Database["public"]["Enums"]["task_status"]; + const supabase = await createClient(); + const response = (await supabase.rpc("end_sync_task", { + s_target: targetN, + s_function: fn, + s_worker: worker, + s_status: info, + })) as PostgrestSingleResponse; + // Transform 204 No Content to 200 OK with success indicator for API consistency + if (response.status === 204) { + response.data = true; + response.status = 200; + } + + return createApiResponse(request, response); + } catch (e: unknown) { + return handleRouteError(request, e, "/api/supabase/sync-task"); + } +}; diff --git a/apps/website/app/api/supabase/sync-task/[fn]/[target]/route.ts b/apps/website/app/api/supabase/sync-task/[fn]/[target]/route.ts new file mode 100644 index 000000000..106705336 --- /dev/null +++ b/apps/website/app/api/supabase/sync-task/[fn]/[target]/route.ts @@ -0,0 +1,88 @@ +import { NextResponse, NextRequest } from "next/server"; +import type { PostgrestSingleResponse } from "@supabase/supabase-js"; +import { createClient } from "~/utils/supabase/server"; +import { + createApiResponse, + handleRouteError, + defaultOptionsHandler, + asPostgrestFailure, +} from "~/utils/supabase/apiUtils"; + +type SyncTaskInfo = { + worker: string; + timeout?: string; + task_interval?: string; +}; + +const SYNC_DEFAULTS: Partial = { + timeout: "20s", + task_interval: "45s", +}; + +type ApiParams = Promise<{ target: string; fn: string }>; +export type SegmentDataType = { params: ApiParams }; + +// POST with the SyncTaskInfo to the /supabase/sync-task/{function_name}/{target} endpoint +export const POST = async ( + request: NextRequest, + segmentData: SegmentDataType, +): Promise => { + try { + const { target, fn } = await segmentData.params; + const targetN = Number.parseInt(target); + if (isNaN(targetN)) { + return createApiResponse( + request, + asPostgrestFailure(`${target} is not a number`, "type"), + ); + } + const info: SyncTaskInfo = { ...SYNC_DEFAULTS, ...(await request.json()) }; + if (!info.worker) { + return createApiResponse( + request, + asPostgrestFailure("Worker field is required", "invalid"), + ); + } + const supabase = await createClient(); + const response = (await supabase.rpc("propose_sync_task", { + s_target: targetN, + s_function: fn, + s_worker: info.worker, + timeout: info.timeout, + task_interval: info.task_interval, + })) as PostgrestSingleResponse; + if (response.data === null) { + // NextJS responses cannot handle null values, convert to boolean success indicator + response.data = true; + } + + return createApiResponse(request, response); + } catch (e: unknown) { + return handleRouteError(request, e, "/api/supabase/route"); + } +}; + +// GET the sync_info table from /supabase/sync-task/{function_name}/{target} (should not be necessary) +export const GET = async ( + request: NextRequest, + segmentData: SegmentDataType, +): Promise => { + const { target, fn } = await segmentData.params; + const targetN = Number.parseInt(target); + if (isNaN(targetN)) { + return createApiResponse( + request, + asPostgrestFailure(`${targetN} is not a number`, "type"), + ); + } + const supabase = await createClient(); + const response = await supabase + .from("sync_info") + .select() + .eq("sync_target", targetN) + .eq("sync_function", fn) + .maybeSingle(); + return createApiResponse(request, response); +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/packages/database/doc/sync_functions.md b/packages/database/doc/sync_functions.md index 131961912..20b280d80 100644 --- a/packages/database/doc/sync_functions.md +++ b/packages/database/doc/sync_functions.md @@ -1,11 +1,11 @@ # Sync information -The `sync_info` table is meant to always be accessed through one of these two functions: `propose_sync_task` and `end_sync_task`. +The `sync_info` table is meant to always be accessed through one of these two functions: `propose_sync_task` and `end_sync_task`, used through POSTS to the web api endpoints `api/supabase/sync-task/[fn]/[target]` and `api/supabase/sync-task/[fn]/[target]/[worker]` respectively. This acts as a semaphore, so that two workers (e.g. the roam plugin on two different browsers) do not try to run the same sync task at the same time. So you need to give the function `propose_sync_task` enough information to distinguish what you mean to do: 1. The `target`, e.g. the database Id of the scope of the task, usually a space, but it could be a single content or concept (for reactive updates) -2. a `function` name, to distinguish different tasks on the same target; e.g. adding vs deleting content. (arbitrary short string) -3. the `worker` name: random string, should be the same between calls. +2. a `function` name, to distinguish different tasks on the same target; e.g. adding vs deleting content. (arbitrary short string) +3. the `worker` name: random string, should be the same between calls. Further, you may specify the `timeout` (>= 1s) after which the task should be deemed to have failed. The `task_interval` (>=5s) which is how often to do the task. (This must be longer than the `timeout`.)