diff --git a/apps/website/app/api/embeddings/openai/small/route.ts b/apps/website/app/api/embeddings/openai/small/route.ts new file mode 100644 index 000000000..11da80051 --- /dev/null +++ b/apps/website/app/api/embeddings/openai/small/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from "next/server"; +import OpenAI from "openai"; +import cors from "~/utils/llm/cors"; + +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + console.error( + "Missing OPENAI_API_KEY environment variable. The embeddings API will not function.", + ); +} + +const openai = apiKey ? new OpenAI({ apiKey }) : null; + +type RequestBody = { + input: string | string[]; + model?: string; + dimensions?: number; + encoding_format?: "float" | "base64"; +}; + +const OPENAI_REQUEST_TIMEOUT_MS = 30000; + +export const POST = async (req: NextRequest): Promise => { + let response: NextResponse; + + if (!apiKey) { + response = NextResponse.json( + { + error: "Server configuration error.", + details: "Embeddings service is not configured.", + }, + { status: 500 }, + ); + return cors(req, response) as NextResponse; + } + + try { + const body: RequestBody = await req.json(); + const { + input, + model = "text-embedding-3-small", + dimensions, + encoding_format = "float", + } = body; + + if (!input || (Array.isArray(input) && input.length === 0)) { + response = NextResponse.json( + { error: "Input text cannot be empty." }, + { status: 400 }, + ); + return cors(req, response) as NextResponse; + } + + const options: OpenAI.EmbeddingCreateParams = { + model, + input, + dimensions, + encoding_format, + }; + + const embeddingsPromise = openai!.embeddings.create(options); + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error("OpenAI API request timeout")), + OPENAI_REQUEST_TIMEOUT_MS, + ), + ); + + const openAIResponse = (await Promise.race([ + embeddingsPromise, + timeoutPromise, + ])) as OpenAI.CreateEmbeddingResponse; + + response = NextResponse.json(openAIResponse, { status: 200 }); + } catch (error: unknown) { + console.error("Error calling OpenAI Embeddings API:", error); + const errorMessage = + process.env.NODE_ENV === "development" + ? error instanceof Error + ? error.message + : "Unknown error" + : "Internal server error"; + response = NextResponse.json( + { + error: "Failed to generate embeddings.", + details: errorMessage, + }, + { status: 500 }, + ); + } + + return cors(req, response) as NextResponse; +}; + +export const OPTIONS = async (req: NextRequest): Promise => { + return cors(req, new NextResponse(null, { status: 204 })) as NextResponse; +}; diff --git a/apps/website/app/api/supabase/account/[id].ts b/apps/website/app/api/supabase/account/[id].ts new file mode 100644 index 000000000..86e8b0ca9 --- /dev/null +++ b/apps/website/app/api/supabase/account/[id].ts @@ -0,0 +1,11 @@ +import { + defaultOptionsHandler, + makeDefaultGetHandler, + makeDefaultDeleteHandler, +} from "~/utils/supabase/apiUtils"; + +export const GET = makeDefaultGetHandler("Account"); + +export const OPTIONS = defaultOptionsHandler; + +export const DELETE = makeDefaultDeleteHandler("Account"); diff --git a/apps/website/app/api/supabase/account/route.ts b/apps/website/app/api/supabase/account/route.ts new file mode 100644 index 000000000..30d352ff1 --- /dev/null +++ b/apps/website/app/api/supabase/account/route.ts @@ -0,0 +1,70 @@ +import { NextResponse, NextRequest } from "next/server"; +import type { PostgrestSingleResponse } from "@supabase/supabase-js"; + +import { createClient } from "~/utils/supabase/server"; +import { getOrCreateEntity, ItemValidator } from "~/utils/supabase/dbUtils"; +import { + createApiResponse, + handleRouteError, + defaultOptionsHandler, + asPostgrestFailure, +} from "~/utils/supabase/apiUtils"; +import { Tables, TablesInsert } from "@repo/database/types.gen.ts"; + +type AccountDataInput = TablesInsert<"Account">; +type AccountRecord = Tables<"Account">; + +const validateAccount: ItemValidator = (account) => { + if (!account || typeof account !== "object") + return "Invalid request body: expected a JSON object."; + if (!account.agent_id) return "Missing required agent_id"; + if (!account.platform_id) return "Missing required platform_id"; + return null; +}; + +const getOrCreateAccount = async ( + supabasePromise: ReturnType, + accountData: AccountDataInput, +): Promise> => { + const { + agent_id, + platform_id, + active = true, + write_permission = true, + account_local_id, + } = accountData; + + const error = validateAccount(accountData); + if (error !== null) return asPostgrestFailure(error, "invalid"); + + const supabase = await supabasePromise; + + const result = await getOrCreateEntity<"Account">({ + supabase, + tableName: "Account", + insertData: { + agent_id, + platform_id, + active, + write_permission, + account_local_id, + }, + uniqueOn: ["agent_id", "platform_id"], + }); + return result; +}; + +export const POST = async (request: NextRequest): Promise => { + const supabasePromise = createClient(); + + try { + const body: AccountDataInput = await request.json(); + const result = await getOrCreateAccount(supabasePromise, body); + + return createApiResponse(request, result); + } catch (e: unknown) { + return handleRouteError(request, e, "/api/supabase/account"); + } +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/api/supabase/content-embedding/[id].ts b/apps/website/app/api/supabase/content-embedding/[id].ts new file mode 100644 index 000000000..c2208ef85 --- /dev/null +++ b/apps/website/app/api/supabase/content-embedding/[id].ts @@ -0,0 +1,19 @@ +import { + defaultOptionsHandler, + makeDefaultGetHandler, + makeDefaultDeleteHandler, +} from "~/utils/supabase/apiUtils"; + +// TODO: Make model agnostic + +export const GET = makeDefaultGetHandler( + "ContentEmbedding_openai_text_embedding_3_small_1536", + "targetId", +); + +export const DELETE = makeDefaultDeleteHandler( + "ContentEmbedding_openai_text_embedding_3_small_1536", + "targetId", +); + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/api/supabase/content-embedding/batch/route.ts b/apps/website/app/api/supabase/content-embedding/batch/route.ts new file mode 100644 index 000000000..c3f25e9ef --- /dev/null +++ b/apps/website/app/api/supabase/content-embedding/batch/route.ts @@ -0,0 +1,132 @@ +import { NextResponse, NextRequest } from "next/server"; +import type { PostgrestResponse } from "@supabase/supabase-js"; + +import { createClient } from "~/utils/supabase/server"; +import { + createApiResponse, + handleRouteError, + defaultOptionsHandler, + asPostgrestFailure, +} from "~/utils/supabase/apiUtils"; +import { + processAndInsertBatch, + KNOWN_EMBEDDING_TABLES, +} from "~/utils/supabase/dbUtils"; +import { + ApiInputEmbeddingItem, + ApiOutputEmbeddingRecord, + embeddingInputProcessing, + embeddingOutputProcessing, +} from "~/utils/supabase/validators"; + +const DEFAULT_MODEL = "openai_text_embedding_3_small_1536"; + +const batchInsertEmbeddingsProcess = async ( + supabase: Awaited>, + embeddingItems: ApiInputEmbeddingItem[], +): Promise> => { + // groupBy is node21 only, we are using 20. Group by model, by hand. + // Note: This means that later index values may be totally wrong. + // Note2: The key is a ModelName, but I cannot use an enum as a key. + const byModel: { [key: string]: ApiInputEmbeddingItem[] } = {}; + try { + embeddingItems.reduce((acc, item) => { + const model = item?.model || DEFAULT_MODEL; + if (acc[model] === undefined) { + acc[model] = []; + } + acc[model]!.push(item); + return acc; + }, byModel); + } catch (error) { + if (error instanceof Error) { + return asPostgrestFailure(error.message, "exception"); + } + throw error; + } + + const globalResults: ApiOutputEmbeddingRecord[] = []; + const partialErrors: string[] = []; + let created = false, + count = 0, + has_400 = false; + for (const modelName of Object.keys(byModel)) { + const embeddingItemsSet = byModel[modelName]; + if (embeddingItemsSet === undefined) continue; + const tableData = KNOWN_EMBEDDING_TABLES[modelName]; + if (tableData === undefined) continue; + const results = await processAndInsertBatch< + // any ContentEmbedding table for type checking purposes only + "ContentEmbedding_openai_text_embedding_3_small_1536", + ApiInputEmbeddingItem, + ApiOutputEmbeddingRecord + >({ + supabase, + items: embeddingItemsSet, + tableName: tableData.tableName, + inputProcessor: embeddingInputProcessing, + outputProcessor: embeddingOutputProcessing, + }); + if (results.data) { + count += results.data.length; + globalResults.push(...results.data); + created = created || results.status === 201; + } else { + partialErrors.push(results.error.message); + if (results.status === 400) has_400 = true; + } + } + if (count > 0) { + if (partialErrors.length > 0) { + return { + data: globalResults, + error: null, + status: has_400 ? 400 : 500, + count, + statusText: partialErrors.join("; "), + }; + } else + return { + data: globalResults, + error: null, + status: created ? 201 : 200, + count, + statusText: created ? "created" : "success", + }; + } else { + return asPostgrestFailure( + partialErrors.join("; "), + "multiple", + has_400 ? 400 : 500, + ); + } +}; + +export const POST = async (request: NextRequest): Promise => { + const supabase = await createClient(); + + try { + const body: ApiInputEmbeddingItem[] = await request.json(); + if (!Array.isArray(body)) { + return createApiResponse( + request, + asPostgrestFailure( + "Request body must be an array of embedding items.", + "empty", + ), + ); + } + + const result = await batchInsertEmbeddingsProcess(supabase, body); + + return createApiResponse(request, result); + } catch (e: unknown) { + return handleRouteError( + request, + e, + `/api/supabase/content-embedding/batch`, + ); + } +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/api/supabase/content-embedding/route.ts b/apps/website/app/api/supabase/content-embedding/route.ts new file mode 100644 index 000000000..1ccbb8f96 --- /dev/null +++ b/apps/website/app/api/supabase/content-embedding/route.ts @@ -0,0 +1,92 @@ +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"; +import { + getOrCreateEntity, + KNOWN_EMBEDDING_TABLES, +} from "~/utils/supabase/dbUtils"; +import { + ApiInputEmbeddingItem, + ApiOutputEmbeddingRecord, + embeddingInputProcessing, + embeddingOutputProcessing, +} from "~/utils/supabase/validators"; + +const DEFAULT_MODEL = "openai_text_embedding_3_small_1536"; + +const processAndCreateEmbedding = async ( + supabasePromise: ReturnType, + data: ApiInputEmbeddingItem, +): Promise> => { + const { valid, error, processedItem } = embeddingInputProcessing(data); + if ( + !valid || + processedItem === undefined || + processedItem.model === undefined + ) + return asPostgrestFailure(error || "unknown error", "valid"); + const supabase = await supabasePromise; + const tableData = + KNOWN_EMBEDDING_TABLES[processedItem.model || DEFAULT_MODEL]; + + if (!tableData) return asPostgrestFailure("Unknown model", "unknown"); + + const { tableName } = tableData; + // Using getOrCreateEntity, forcing create path by providing non-matching criteria + // This standardizes return type and error handling (e.g., FK violations from dbUtils) + const result = + await getOrCreateEntity<"ContentEmbedding_openai_text_embedding_3_small_1536">( + { + supabase, + tableName, + insertData: processedItem, + }, + ); + + if (result.error) { + return result; + } + + const processedResult = embeddingOutputProcessing(result.data); + if (!processedResult.processedItem) { + return asPostgrestFailure( + processedResult.error || "unknown error", + "postinvalid", + 500, + ); + } + if (processedResult.error) { + // err on the side of returning the data + return { + ...result, + status: 500, + data: processedResult.processedItem, + statusText: processedResult.error, + }; + } + return { + ...result, + data: processedResult.processedItem, + }; +}; + +export const POST = async (request: NextRequest): Promise => { + const supabasePromise = createClient(); + + try { + const body: ApiInputEmbeddingItem = await request.json(); + const result = await processAndCreateEmbedding(supabasePromise, body); + return createApiResponse(request, result); + } catch (e: unknown) { + return handleRouteError(request, e, `/api/supabase/content-embedding`); + } +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/api/supabase/content/[id].ts b/apps/website/app/api/supabase/content/[id].ts new file mode 100644 index 000000000..8903d1743 --- /dev/null +++ b/apps/website/app/api/supabase/content/[id].ts @@ -0,0 +1,11 @@ +import { + defaultOptionsHandler, + makeDefaultGetHandler, + makeDefaultDeleteHandler, +} from "~/utils/supabase/apiUtils"; + +export const GET = makeDefaultGetHandler("Content"); + +export const OPTIONS = defaultOptionsHandler; + +export const DELETE = makeDefaultDeleteHandler("Content"); diff --git a/apps/website/app/api/supabase/content/batch/route.ts b/apps/website/app/api/supabase/content/batch/route.ts new file mode 100644 index 000000000..bcb4cfabe --- /dev/null +++ b/apps/website/app/api/supabase/content/batch/route.ts @@ -0,0 +1,53 @@ +import { NextResponse, NextRequest } from "next/server"; +import type { PostgrestResponse } from "@supabase/supabase-js"; +import { createClient } from "~/utils/supabase/server"; +import { + createApiResponse, + handleRouteError, + defaultOptionsHandler, + asPostgrestFailure, +} from "~/utils/supabase/apiUtils"; +import { validateAndInsertBatch } from "~/utils/supabase/dbUtils"; +import { + contentInputValidation, + type ContentDataInput, + type ContentRecord, +} from "~/utils/supabase/validators"; + +const batchInsertContentProcess = async ( + supabase: Awaited>, + contentItems: ContentDataInput[], +): Promise> => { + return validateAndInsertBatch<"Content">({ + supabase, + tableName: "Content", + items: contentItems, + uniqueOn: ["space_id", "source_local_id"], + inputValidator: contentInputValidation, + }); +}; + +export const POST = async (request: NextRequest): Promise => { + const supabase = await createClient(); + + try { + const body: ContentDataInput[] = await request.json(); + if (!Array.isArray(body)) { + return createApiResponse( + request, + asPostgrestFailure( + "Request body must be an array of content items.", + "array", + ), + ); + } + + const result = await batchInsertContentProcess(supabase, body); + + return createApiResponse(request, result); + } catch (e: unknown) { + return handleRouteError(request, e, "/api/supabase/content/batch"); + } +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/api/supabase/content/route.ts b/apps/website/app/api/supabase/content/route.ts new file mode 100644 index 000000000..ee8ede52c --- /dev/null +++ b/apps/website/app/api/supabase/content/route.ts @@ -0,0 +1,52 @@ +import { NextResponse, NextRequest } from "next/server"; +import type { PostgrestSingleResponse } from "@supabase/supabase-js"; + +import { createClient } from "~/utils/supabase/server"; +import { getOrCreateEntity } from "~/utils/supabase/dbUtils"; +import { + createApiResponse, + handleRouteError, + defaultOptionsHandler, + asPostgrestFailure, +} from "~/utils/supabase/apiUtils"; +import { contentInputValidation } from "~/utils/supabase/validators"; +import { Tables, TablesInsert } from "@repo/database/types.gen.ts"; + +type ContentDataInput = TablesInsert<"Content">; +type ContentRecord = Tables<"Content">; + +const processAndUpsertContentEntry = async ( + supabasePromise: ReturnType, + data: ContentDataInput, +): Promise> => { + const error = contentInputValidation(data); + if (error !== null) return asPostgrestFailure(error, "invalid"); + + const supabase = await supabasePromise; + + // If no solid matchCriteria for a "get", getOrCreateEntity will likely proceed to "create". + // If there are unique constraints other than (space_id, source_local_id), it will handle race conditions. + + const result = await getOrCreateEntity<"Content">({ + supabase, + tableName: "Content", + insertData: data, + uniqueOn: ["space_id", "source_local_id"], + }); + + return result; +}; + +export const POST = async (request: NextRequest): Promise => { + const supabasePromise = createClient(); + + try { + const body: ContentDataInput = await request.json(); + const result = await processAndUpsertContentEntry(supabasePromise, body); + return createApiResponse(request, result); + } catch (e: unknown) { + return handleRouteError(request, e, "/api/supabase/content"); + } +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/api/supabase/document/[id].ts b/apps/website/app/api/supabase/document/[id].ts new file mode 100644 index 000000000..c0d09327b --- /dev/null +++ b/apps/website/app/api/supabase/document/[id].ts @@ -0,0 +1,11 @@ +import { + defaultOptionsHandler, + makeDefaultGetHandler, + makeDefaultDeleteHandler, +} from "~/utils/supabase/apiUtils"; + +export const GET = makeDefaultGetHandler("Document"); + +export const OPTIONS = defaultOptionsHandler; + +export const DELETE = makeDefaultDeleteHandler("Document"); diff --git a/apps/website/app/api/supabase/document/route.ts b/apps/website/app/api/supabase/document/route.ts new file mode 100644 index 000000000..ea07967e3 --- /dev/null +++ b/apps/website/app/api/supabase/document/route.ts @@ -0,0 +1,65 @@ +import { NextResponse, NextRequest } from "next/server"; +import type { PostgrestSingleResponse } from "@supabase/supabase-js"; + +import { createClient } from "~/utils/supabase/server"; +import { getOrCreateEntity, ItemValidator } from "~/utils/supabase/dbUtils"; +import { + createApiResponse, + handleRouteError, + defaultOptionsHandler, + asPostgrestFailure, +} from "~/utils/supabase/apiUtils"; +import { Tables, TablesInsert } from "@repo/database/types.gen.ts"; + +type DocumentDataInput = TablesInsert<"Document">; +type DocumentRecord = Tables<"Document">; + +const validateDocument: ItemValidator = (data) => { + if (!data || typeof data !== "object") + return "Invalid request body: expected a JSON object."; + const { space_id, author_id, source_local_id } = data; + + if (!author_id) return "Missing required author_id field."; + // Note: Those are only mandatory together. + if ((space_id === null) !== (source_local_id === null)) + return "Either specify both source_id and source_local_id or neither."; + return null; +}; + +const createDocument = async ( + supabasePromise: ReturnType, + data: DocumentDataInput, +): Promise> => { + const supabase = await supabasePromise; + + const result = await getOrCreateEntity<"Document">({ + supabase, + tableName: "Document", + insertData: data, + // Note: This is a temporary assumption + // we'll want to have a in-space route with this, + // and an out-of-space context with url. + uniqueOn: ["space_id", "source_local_id"], + }); + + return result; +}; + +export const POST = async (request: NextRequest): Promise => { + const supabasePromise = createClient(); + + try { + const body: DocumentDataInput = await request.json(); + const error = validateDocument(body); + if (error !== null) + return createApiResponse(request, asPostgrestFailure(error, "invalid")); + + const result = await createDocument(supabasePromise, body); + + return createApiResponse(request, result); + } catch (e: unknown) { + return handleRouteError(request, e, "/api/supabase/document"); + } +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/api/supabase/person/[id].ts b/apps/website/app/api/supabase/person/[id].ts new file mode 100644 index 000000000..c5338c75c --- /dev/null +++ b/apps/website/app/api/supabase/person/[id].ts @@ -0,0 +1,11 @@ +import { + defaultOptionsHandler, + makeDefaultGetHandler, + makeDefaultDeleteHandler, +} from "~/utils/supabase/apiUtils"; + +export const GET = makeDefaultGetHandler("Person"); + +export const OPTIONS = defaultOptionsHandler; + +export const DELETE = makeDefaultDeleteHandler("Person"); diff --git a/apps/website/app/api/supabase/person/route.ts b/apps/website/app/api/supabase/person/route.ts new file mode 100644 index 000000000..3b87fdb44 --- /dev/null +++ b/apps/website/app/api/supabase/person/route.ts @@ -0,0 +1,85 @@ +import { NextResponse, NextRequest } from "next/server"; +import type { PostgrestSingleResponse } from "@supabase/supabase-js"; + +import { createClient } from "~/utils/supabase/server"; +import { getOrCreateEntity, ItemValidator } from "~/utils/supabase/dbUtils"; +import { + createApiResponse, + handleRouteError, + defaultOptionsHandler, + asPostgrestFailure, +} from "~/utils/supabase/apiUtils"; +import { Tables, TablesInsert } from "@repo/database/types.gen.ts"; + +type PersonDataInput = TablesInsert<"Person">; +type PersonRecord = Tables<"Person">; + +const personValidator: ItemValidator = (person) => { + if (!person || typeof person !== "object") + return "Invalid request body: expected a JSON object."; + const { name, email } = person; + + if (!name || typeof name !== "string" || name.trim() === "") + return "Missing or invalid name for Person."; + if (!email || typeof email !== "string" || email.trim() === "") + return "Missing or invalid email for Person."; + return null; +}; + +const getOrCreatePersonInternal = async ( + supabasePromise: ReturnType, + email: string, + name: string, + orcid: string | null | undefined, +): Promise> => { + const supabase = await supabasePromise; + // TODO: Rewrite in a transaction with the ORM later. + const agentResponse = await getOrCreateEntity<"Agent">({ + supabase, + tableName: "Agent", + insertData: { type: "Person" }, + }); + if (agentResponse.error || agentResponse.data === null) + return agentResponse as any as PostgrestSingleResponse; + const result = await getOrCreateEntity<"Person">({ + supabase, + tableName: "Person", + insertData: { + id: agentResponse.data.id, + email: email.trim(), + name: name.trim(), + orcid: orcid || null, + }, + uniqueOn: ["email"], + }); + if (result.error) { + await supabase.from("Agent").delete().eq("id", agentResponse.data.id); + // not much to do if an error here + } + return result; +}; + +export const POST = async (request: NextRequest): Promise => { + const supabasePromise = createClient(); + + try { + const body: PersonDataInput = await request.json(); + const { name, email, orcid = null } = body; + const error = personValidator(body); + if (error !== null) + return createApiResponse(request, asPostgrestFailure(error, "invalid")); + + const personResult = await getOrCreatePersonInternal( + supabasePromise, + email, + name, + orcid, + ); + + return createApiResponse(request, personResult); + } catch (e: unknown) { + return handleRouteError(request, e, "/api/supabase/person"); + } +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/api/supabase/platform/[id].ts b/apps/website/app/api/supabase/platform/[id].ts new file mode 100644 index 000000000..a376cb411 --- /dev/null +++ b/apps/website/app/api/supabase/platform/[id].ts @@ -0,0 +1,11 @@ +import { + defaultOptionsHandler, + makeDefaultGetHandler, + makeDefaultDeleteHandler, +} from "~/utils/supabase/apiUtils"; + +export const GET = makeDefaultGetHandler("Platform"); + +export const OPTIONS = defaultOptionsHandler; + +export const DELETE = makeDefaultDeleteHandler("Platform"); diff --git a/apps/website/app/api/supabase/platform/route.ts b/apps/website/app/api/supabase/platform/route.ts new file mode 100644 index 000000000..de0b7de99 --- /dev/null +++ b/apps/website/app/api/supabase/platform/route.ts @@ -0,0 +1,80 @@ +import { NextResponse, NextRequest } from "next/server"; +import type { PostgrestSingleResponse } from "@supabase/supabase-js"; + +import { createClient } from "~/utils/supabase/server"; +import { getOrCreateEntity, ItemValidator } from "~/utils/supabase/dbUtils"; +import { + createApiResponse, + handleRouteError, + defaultOptionsHandler, + asPostgrestFailure, +} from "~/utils/supabase/apiUtils"; +import { Tables, TablesInsert } from "@repo/database/types.gen.ts"; + +type PlatformDataInput = TablesInsert<"Platform">; +type PlatformRecord = Tables<"Platform">; + +const platformValidator: ItemValidator = (platform) => { + if (!platform || typeof platform !== "object") + return "Invalid request body: expected a JSON object."; + + if (!platform.url || typeof platform.url !== "string") { + return "Missing or invalid url field."; + } + + const lowerCaseURL = platform.url.toLowerCase(); + + if (!lowerCaseURL.includes("roamresearch.com")) + return "Could not determine platform from URL:"; + return null; +}; + +const getOrCreatePlatformFromURL = async ( + supabasePromise: ReturnType, + platform: PlatformDataInput, +): Promise> => { + const error = platformValidator(platform); + if (error !== null) return asPostgrestFailure(error, "invalid"); + const lowerCaseURL = platform.url.toLowerCase(); + + if (lowerCaseURL.includes("roamresearch.com")) { + platform.name = "roamresearch"; + platform.url = "https://roamresearch.com"; + } else { + throw Error("No path should reach here."); + } + + const supabase = await supabasePromise; + return getOrCreateEntity<"Platform">({ + supabase, + tableName: "Platform", + insertData: platform, + uniqueOn: ["url"], + }); +}; + +export const POST = async (request: NextRequest): Promise => { + const supabase = createClient(); + + try { + const body: PlatformDataInput = await request.json(); + const { url } = body; + + if (!url || typeof url !== "string") { + return createApiResponse( + request, + asPostgrestFailure( + "Missing or invalid url in request body.", + "invalid", + ), + ); + } + + const result = await getOrCreatePlatformFromURL(supabase, body); + return createApiResponse(request, result); + } catch (e: unknown) { + return handleRouteError(request, e, "/api/supabase/platform"); + } +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/api/supabase/space/[id].ts b/apps/website/app/api/supabase/space/[id].ts new file mode 100644 index 000000000..0de4197c1 --- /dev/null +++ b/apps/website/app/api/supabase/space/[id].ts @@ -0,0 +1,11 @@ +import { + defaultOptionsHandler, + makeDefaultGetHandler, + makeDefaultDeleteHandler, +} from "~/utils/supabase/apiUtils"; + +export const GET = makeDefaultGetHandler("Space"); + +export const OPTIONS = defaultOptionsHandler; + +export const DELETE = makeDefaultDeleteHandler("Space"); diff --git a/apps/website/app/api/supabase/space/route.ts b/apps/website/app/api/supabase/space/route.ts new file mode 100644 index 000000000..c4fbb0020 --- /dev/null +++ b/apps/website/app/api/supabase/space/route.ts @@ -0,0 +1,74 @@ +import { NextResponse, NextRequest } from "next/server"; +import type { PostgrestSingleResponse } from "@supabase/supabase-js"; + +import { createClient } from "~/utils/supabase/server"; +import { getOrCreateEntity, ItemValidator } from "~/utils/supabase/dbUtils"; +import { + createApiResponse, + handleRouteError, + defaultOptionsHandler, + asPostgrestFailure, +} from "~/utils/supabase/apiUtils"; +import { Tables, TablesInsert } from "@repo/database/types.gen.ts"; + +type SpaceDataInput = TablesInsert<"Space">; +type SpaceRecord = Tables<"Space">; + +const spaceValidator: ItemValidator = (space) => { + if (!space || typeof space !== "object") + return "Invalid request body: expected a JSON object."; + const { name, url, platform_id } = space; + + if (!name || typeof name !== "string" || name.trim() === "") + return "Missing or invalid name."; + if (!url || typeof url !== "string" || url.trim() === "") + return "Missing or invalid URL."; + if ( + platform_id === undefined || + platform_id === null || + typeof platform_id !== "number" + ) + return "Missing or invalid platform_id."; + return null; +}; + +const processAndGetOrCreateSpace = async ( + supabasePromise: ReturnType, + data: SpaceDataInput, +): Promise> => { + const { name, url, platform_id } = data; + const error = spaceValidator(data); + if (error !== null) return asPostgrestFailure(error, "invalid"); + + const normalizedUrl = url.trim().replace(/\/$/, ""); + const trimmedName = name.trim(); + const supabase = await supabasePromise; + + const result = await getOrCreateEntity<"Space">({ + supabase, + tableName: "Space", + insertData: { + name: trimmedName, + url: normalizedUrl, + platform_id: platform_id, + }, + uniqueOn: ["url"], + }); + + return result; +}; + +export const POST = async (request: NextRequest): Promise => { + const supabasePromise = createClient(); + + try { + const body: SpaceDataInput = await request.json(); + + const result = await processAndGetOrCreateSpace(supabasePromise, body); + return createApiResponse(request, result); + } catch (e: unknown) { + return handleRouteError(request, e, "/api/supabase/space"); + } +}; + +export const OPTIONS = defaultOptionsHandler; diff --git a/apps/website/app/utils/supabase/apiUtils.ts b/apps/website/app/utils/supabase/apiUtils.ts new file mode 100644 index 000000000..d4ce8950c --- /dev/null +++ b/apps/website/app/utils/supabase/apiUtils.ts @@ -0,0 +1,156 @@ +import { NextResponse, NextRequest } from "next/server"; +import { + PostgrestResponse, + PostgrestSingleResponse, +} from "@supabase/supabase-js"; + +import { Database } from "@repo/database/types.gen.ts"; +import { createClient } from "~/utils/supabase/server"; +import cors from "~/utils/llm/cors"; + +/** + * Sends a standardized JSON response. + * @param request The original NextRequest. + * @param data The data payload for successful responses. + * @param error An error message string if the operation failed. + * @param details Optional detailed error information. + * @param status The HTTP status code for the response. + * @param created A boolean indicating if a resource was created (influences status code: 201 vs 200). + */ +export const createApiResponse = ( + request: NextRequest, + payload: PostgrestResponse | PostgrestSingleResponse, +): NextResponse => { + let response: NextResponse; + const { data, error, status } = payload; + + if (error) { + response = NextResponse.json( + { error: error.message, details: error.details || undefined }, + { status }, + ); + } else if (data !== undefined && data !== null) { + response = NextResponse.json(data, { status }); + } else { + // Fallback for unexpected state (e.g. no error, but no data for a success status) + console.error( + `API Response Error: Attempted to send success response (status ${status}) with no data and no error.`, + ); + response = NextResponse.json( + { + error: + "An unexpected server error occurred during response generation.", + }, + { status: 500 }, + ); + } + return cors(request, response) as NextResponse; +}; + +/** + * Handles errors caught in the main try-catch block of an API route. + * Differentiates JSON parsing errors from other errors. + */ +export const handleRouteError = ( + request: NextRequest, + error: unknown, + routeName: string, +): NextResponse => { + console.error(`API route error in ${routeName}:`, error); + if ( + error instanceof SyntaxError && + error.message.toLowerCase().includes("json") + ) { + return createApiResponse( + request, + asPostgrestFailure("Invalid JSON in request body.", "invalid"), + ); + } + const message = + error instanceof Error + ? error.message + : "An unexpected error occurred processing your request."; + return createApiResponse(request, asPostgrestFailure(message, "invalid")); +}; + +/** + * Default OPTIONS handler for CORS preflight requests. + */ +export const defaultOptionsHandler = async ( + request: NextRequest, +): Promise => { + const response = new NextResponse(null, { status: 204 }); + return cors(request, response) as NextResponse; +}; + +type ApiParams = Promise<{ id: string }>; +export type SegmentDataType = { params: ApiParams }; + +/** + * Default GET handler for retrieving a resource by Id + */ +export const makeDefaultGetHandler = + (tableName: keyof Database["public"]["Tables"], pk: string = "id") => + async ( + request: NextRequest, + segmentData: SegmentDataType, + ): Promise => { + const { id } = await segmentData.params; + const idN = Number.parseInt(id); + if (isNaN(idN)) { + return createApiResponse( + request, + asPostgrestFailure(`${pk} is not a number`, "type"), + ); + } + const supabase = await createClient(); + const response = await supabase + .from(tableName) + .select() + .eq(pk, idN) + .maybeSingle(); + return createApiResponse(request, response); + }; + +/** + * Default DELETE handler for deleting a resource by ID + */ +export const makeDefaultDeleteHandler = + (tableName: keyof Database["public"]["Tables"], pk: string = "id") => + async ( + request: NextRequest, + segmentData: SegmentDataType, + ): Promise => { + const { id } = await segmentData.params; + const idN = Number.parseInt(id); + if (isNaN(idN)) { + return createApiResponse( + request, + asPostgrestFailure(`${pk} is not a number`, "type"), + ); + } + const supabase = await createClient(); + + const response = await supabase.from(tableName).delete().eq(pk, idN); + return createApiResponse(request, response); + }; + +export const asPostgrestFailure = ( + message: string, + code: string, + status: number = 400, +): PostgrestSingleResponse => { + return { + data: null, + error: { + message, + code, + details: "", + hint: "", + name: code, + }, + count: null, + statusText: code, + status, + }; +}; diff --git a/apps/website/app/utils/supabase/dbUtils.ts b/apps/website/app/utils/supabase/dbUtils.ts new file mode 100644 index 000000000..6841b797e --- /dev/null +++ b/apps/website/app/utils/supabase/dbUtils.ts @@ -0,0 +1,476 @@ +import type { + SupabaseClient, + PostgrestResponse, + PostgrestSingleResponse, +} from "@supabase/supabase-js"; +import { Database, Tables, TablesInsert } from "@repo/database/types.gen.ts"; + +export const KNOWN_EMBEDDING_TABLES: { + [key: string]: { + tableName: keyof Database["public"]["Tables"]; + tableSize: number; + }; +} = { + openai_text_embedding_3_small_1536: { + tableName: "ContentEmbedding_openai_text_embedding_3_small_1536", + tableSize: 1536, + }, +}; + +const UNIQUE_KEY_RE = /^Key \(([^)]+)\)=\(([\^)]+)\) already exists\.$/; +const UNIQUE_INDEX_RE = + /duplicate key value violates unique constraint "(\w+)"/; +const FOREIGN_KEY_RE = + /Keys? \(([^)]+)\)=\(([^)]+)\) (is|are) not present in table ("?\w+"?)./; +const FOREIGN_CONSTRAINT_RE = + /insert or update on table ("?\w+"?) violates foreign key constraint ("?\w+"?)/; + +const processSupabaseError = ( + response: PostgrestResponse, + tableName: string, +): PostgrestResponse => { + const { error } = response; + if (error == null) return response; // should not happen, but makes TS happy + console.error(`Error inserting new ${tableName}:`, error); + if (error.code === "23505") { + // Handle race condition: unique constraint violation (PostgreSQL error code '23505') + console.warn( + `Unique constraint violation on ${tableName} insert .\nError is ${error.message}, ${error.hint}.`, + ); + const dup_key_data = UNIQUE_KEY_RE.exec(error.hint); + if (dup_key_data !== null && dup_key_data.length > 1) { + const uniqueOn = dup_key_data[1]!.split(",").map((x) => x.trim()); + if (uniqueOn.length === 0) { + const idx_data = UNIQUE_INDEX_RE.exec(error.message); + response.error.message = + idx_data === null + ? "Could not identify the keys or index of the unique constraint" + : `Could not identify the keys of the unique key index ${idx_data[1]}`; + response.status = 400; + return response; + } + } + response.error.message = `Unique constraint violation on ${tableName} insert, and re-fetch failed to find the entity.`; + response.status = 409; // Conflict, and couldn't resolve by re-fetching + } else if (error.code === "23503") { + // Handle foreign key constraint violations (PostgreSQL error code '23503') + const fkey_data = FOREIGN_KEY_RE.exec(error.hint); + const constraint_data = FOREIGN_CONSTRAINT_RE.exec(error.message); + console.warn( + `Foreign violation on ${tableName}, constraint ${constraint_data ? constraint_data[2] : "unknown"}, keys ${fkey_data ? fkey_data[1] : "unknown"}`, + ); + response.error.message = + fkey_data === null + ? constraint_data === null + ? "Could not identify the missing foreign key" + : `Foreign key constraint ${constraint_data[2]} violated` + : `Foreign key ${fkey_data[1]} is missing value ${fkey_data[2]}`; + response.status = 400; + } + return response; +}; + +/** + * Generic function to get an entity or create it if it doesn't exist. + * Handles common race conditions for unique constraint violations. + * + * @param supabase Supabase client instance. + * @param tableName The name of the table. + * @param insertData Data to upsert + * @param uniqueOn The expected uniqueOn key. + * @returns Promise> + */ +export const getOrCreateEntity = async < + TableName extends keyof Database["public"]["Tables"], +>({ + supabase, + tableName, + insertData, + uniqueOn = undefined, +}: { + supabase: SupabaseClient; + tableName: keyof Database["public"]["Tables"]; + insertData: TablesInsert; + uniqueOn?: (keyof TablesInsert)[]; // Uses pKey otherwise +}): Promise>> => { + const result: PostgrestSingleResponse> = await supabase + .from(tableName) + .upsert(insertData, { + onConflict: uniqueOn === undefined ? undefined : uniqueOn.join(","), + ignoreDuplicates: false, + count: "estimated", + }) + .single() + .overrideTypes>(); + const { error: insertError } = result; + if (insertError) { + if (insertError.code === "23505") { + // Handle race condition: unique constraint violation (PostgreSQL error code '23505') + const dup_key_data = UNIQUE_KEY_RE.exec(insertError.hint); + if (dup_key_data !== null && dup_key_data.length > 1) + uniqueOn = dup_key_data[1]! + .split(",") + .map((x) => x.trim()) as (keyof TablesInsert)[]; + if (uniqueOn && uniqueOn.length > 0) { + console.warn(`Attempting to re-fetch using ${uniqueOn.join(", ")}`); + let reFetchQueryBuilder = supabase.from(tableName).select(); + for (let i = 0; i < uniqueOn.length; i++) { + const key: keyof TablesInsert = uniqueOn[i]!; + reFetchQueryBuilder = reFetchQueryBuilder.eq( + key as string, + insertData[key] as any, // TS gets confused here? + ); + } + const reFetchResult = + await reFetchQueryBuilder.maybeSingle>(); + const { data: reFetchedEntity, error: reFetchError } = reFetchResult; + + if (reFetchResult === null) { + result.error.message = `Unique constraint violation on on (${uniqueOn.join(", ")}) in ${tableName}, and re-fetch failed to find the entity because of ${reFetchError}.`; + result.status = 500; + return result; + } else { + console.log(`Found ${tableName} on re-fetch:`, reFetchedEntity); + // Note: Using a PostgrestResult means I cannot have both data and error non-null... + return { + error: { + ...result.error, + message: `Upsert failed because of conflict with this entity: ${reFetchedEntity}"`, + }, + statusText: result.statusText, + data: null, + count: null, + status: 400, + }; + } + } + } + processSupabaseError(result, tableName); + } + return result; +}; + +export type BatchItemValidator = ( + item: TInput, + index: number, +) => { valid: boolean; error?: string; processedItem?: TProcessed }; + +export type ItemProcessor = (item: TInput) => { + valid: boolean; + error?: string; + processedItem?: TProcessed; +}; + +export type ItemValidator = (item: T) => string | null; + +export const InsertValidatedBatch = async < + TableName extends keyof Database["public"]["Tables"], +>({ + supabase, + tableName, + items, + uniqueOn = undefined, +}: { + supabase: SupabaseClient; + tableName: keyof Database["public"]["Tables"]; + items: TablesInsert[]; + uniqueOn?: (keyof TablesInsert)[]; // Uses pKey otherwise +}): Promise>> => { + const result: PostgrestResponse> = await supabase + .from(tableName) + .upsert(items, { + onConflict: uniqueOn === undefined ? undefined : uniqueOn.join(","), + ignoreDuplicates: false, + count: "estimated", + }) + .select(); + const { data, error, status } = result; + + if (error !== null) { + processSupabaseError(result, tableName); + return result; + } + + if (data.length !== items.length) { + console.warn( + `Batch insert ${tableName}: Mismatch between processed count (${items.length}) and DB returned count (${data?.length || 0}).`, + ); + result.statusText = `Batch insert of ${tableName} might have partially failed or returned unexpected data.`; + result.status = 500; + } + + console.log( + `Successfully batch inserted ${data.length} ${tableName} records.`, + ); + return result; +}; + +export const validateAndInsertBatch = async < + TableName extends keyof Database["public"]["Tables"], +>({ + supabase, + tableName, + items, + uniqueOn = undefined, + inputValidator = undefined, + outputValidator = undefined, +}: { + supabase: SupabaseClient; + tableName: keyof Database["public"]["Tables"]; + items: TablesInsert[]; + uniqueOn?: (keyof TablesInsert)[]; // Uses pKey otherwise + inputValidator?: ItemValidator>; + outputValidator?: ItemValidator>; +}): Promise>> => { + let validatedItems: TablesInsert[] = []; + const validationErrors: { index: number; error: string }[] = []; + if (!Array.isArray(items) || items.length === 0) { + return { + error: { + message: `Request body must be a non-empty array of ${tableName} items.`, + details: "", + hint: "", + code: "1", + name: "nonempty", + }, + status: 400, + data: null, + count: null, + statusText: "Empty input", + }; + } + + if (inputValidator !== undefined) { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item) { + // Handles undefined/null items in the array itself + validationErrors.push({ + index: i, + error: "Item is undefined or null.", + }); + continue; + } + const error = inputValidator(item); + if (error !== null) { + validationErrors.push({ + index: i, + error: error || "Validation failed.", + }); + } else { + validatedItems.push(item); + } + } + + if (validationErrors.length > 0) { + return { + error: { + message: `Validation failed for one or more ${tableName} items.`, + details: `${validationErrors}`, + hint: "", + code: "2", + name: "invalid", + }, + status: 400, + data: null, + count: null, + statusText: "Validation errors", + }; + } + } else { + validatedItems = items; + } + const result = await InsertValidatedBatch({ + supabase, + tableName, + items: validatedItems, + uniqueOn, + }); + if (result.error) { + return result; + } + if (outputValidator !== undefined) { + const validatedResults: Tables[] = []; + for (let i = 0; i < result.data.length; i++) { + const item = result.data[i]; + if (!item) { + // Handles undefined/null items in the array itself + validationErrors.push({ + index: i, + error: "Returned item is undefined or null.", + }); + continue; + } + const error = outputValidator(item); + if (error !== null) { + validationErrors.push({ + index: i, + error: error || `Validation failed for ${item}.`, + }); + } else { + validatedResults.push(item); + } + } + + if (validationErrors.length > 0) { + if (validatedResults.length > 0) { + // Erring on the side of returning data with an error in status. + return { + error: null, + status: 500, + data: validatedResults, + count: validatedResults.length, + statusText: `Validation failed for one or more ${tableName} items, and succeeded for ${validatedResults.length}/${result.data.length}.`, + }; + } else { + return { + error: { + message: `Post-validation failed for all ${tableName} items.`, + details: `${validationErrors}`, + hint: "", + code: "2", + name: "invalid", + }, + status: 500, + data: null, + count: null, + statusText: "post-validation", + }; + } + } + } + return result; +}; + +export const processAndInsertBatch = async < + TableName extends keyof Database["public"]["Tables"], + InputType, + OutputType, +>({ + supabase, + tableName, + items, + uniqueOn = undefined, + inputProcessor, + outputProcessor, +}: { + supabase: SupabaseClient; + tableName: keyof Database["public"]["Tables"]; + items: InputType[]; + uniqueOn?: (keyof TablesInsert)[]; // Uses pKey otherwise + inputProcessor: ItemProcessor>; + outputProcessor: ItemProcessor, OutputType>; +}): Promise> => { + let processedItems: TablesInsert[] = []; + const validationErrors: { index: number; error: string }[] = []; + if (!Array.isArray(items) || items.length === 0) { + return { + error: { + message: `Request body must be a non-empty array of ${tableName} items.`, + details: "", + hint: "", + code: "1", + name: "nonempty", + }, + status: 400, + data: null, + count: null, + statusText: "Empty input", + }; + } + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item) { + // Handles undefined/null items in the array itself + validationErrors.push({ + index: i, + error: "Item is undefined or null.", + }); + continue; + } + const { valid, error, processedItem } = inputProcessor(item); + if (!valid || !processedItem) { + validationErrors.push({ + index: i, + error: error || "Validation failed.", + }); + } else { + processedItems.push(processedItem); + } + } + + if (validationErrors.length > 0) { + return { + error: { + message: `Validation failed for one or more ${tableName} items.`, + details: `${validationErrors}`, + hint: "", + code: "2", + name: "invalid", + }, + status: 400, + data: null, + count: null, + statusText: "Validation errors", + }; + } + const result = await InsertValidatedBatch({ + supabase, + tableName, + items: processedItems, + uniqueOn, + }); + if (result.error) { + return result; + } + const processedResults: OutputType[] = []; + for (let i = 0; i < result.data.length; i++) { + const item = result.data[i]; + if (!item) { + // Handles undefined/null items in the array itself + validationErrors.push({ + index: i, + error: "Returned item is undefined or null.", + }); + continue; + } + const { valid, error, processedItem } = outputProcessor(item); + if (!valid || !processedItem) { + validationErrors.push({ + index: i, + error: error || "Result validation failed.", + }); + } else { + processedResults.push(processedItem); + } + } + + if (validationErrors.length > 0) { + if (processedResults.length > 0) { + // Erring on the side of returning data with an error in status. + return { + error: null, + status: 500, + data: processedResults, + count: processedResults.length, + statusText: `Validation failed for one or more ${tableName} items, and succeeded for ${processedResults.length}/${result.data.length}.`, + }; + } else { + return { + error: { + message: `Post-validation failed for all ${tableName} items.`, + details: `${validationErrors}`, + hint: "", + code: "2", + name: "invalid", + }, + status: 500, + data: null, + count: null, + statusText: "post-validation", + }; + } + } + return { ...result, data: processedResults }; +}; diff --git a/apps/website/app/utils/supabase/server.ts b/apps/website/app/utils/supabase/server.ts new file mode 100644 index 000000000..080c16f8b --- /dev/null +++ b/apps/website/app/utils/supabase/server.ts @@ -0,0 +1,47 @@ +import { createServerClient, type CookieOptions } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { Database } from "@repo/database/types.gen.ts"; + +export const createClient = async () => { + const cookieStore = await cookies(); + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseKey) { + throw new Error("Missing required Supabase environment variables"); + } + + // following https://supabase.com/docs/guides/auth/server-side/creating-a-client?queryGroups=environment&environment=server + return createServerClient(supabaseUrl, supabaseKey, { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll( + cookiesToSet: { + name: string; + value: string; + options: CookieOptions; + }[], + ) { + try { + cookiesToSet.forEach( + ({ + name, + value, + options, + }: { + name: string; + value: string; + options: CookieOptions; + }) => cookieStore.set(name, value, options), + ); + } catch { + // The `setAll` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + }, + }); +}; diff --git a/apps/website/app/utils/supabase/validators.ts b/apps/website/app/utils/supabase/validators.ts new file mode 100644 index 000000000..918fb75d6 --- /dev/null +++ b/apps/website/app/utils/supabase/validators.ts @@ -0,0 +1,160 @@ +import { + KNOWN_EMBEDDING_TABLES, + ItemProcessor, + ItemValidator, +} from "./dbUtils"; +import { Tables, TablesInsert } from "@repo/database/types.gen.ts"; + +// Use the first known ContentEmbedding table for type checking, as they have the same structure +export type ContentEmbeddingDataInput = + TablesInsert<"ContentEmbedding_openai_text_embedding_3_small_1536">; +export type ContentEmbeddingRecord = + Tables<"ContentEmbedding_openai_text_embedding_3_small_1536">; + +export type ApiInputEmbeddingItem = Omit< + ContentEmbeddingDataInput, + "vector" +> & { + vector: number[]; +}; + +export type ApiOutputEmbeddingRecord = Omit< + ContentEmbeddingRecord, + "vector" +> & { + vector: number[]; +}; + +export const embeddingInputValidation: ItemValidator = ( + data, +) => { + if (!data || typeof data !== "object") + return "Invalid request body: expected a JSON object."; + const { target_id, model, vector } = data; + + if ( + target_id === undefined || + target_id === null || + typeof target_id !== "number" + ) { + return "Missing or invalid target_id."; + } + if ( + !model || + typeof model !== "string" || + KNOWN_EMBEDDING_TABLES[model] == undefined + ) { + return "Missing or invalid model name."; + } + const { tableSize } = KNOWN_EMBEDDING_TABLES[model]; + + if ( + !vector || + !Array.isArray(vector) || + !vector.every((v) => typeof v === "number") + ) { + return "Missing or invalid vector. Must be an array of numbers."; + } + if (vector.length !== tableSize) { + return `Invalid vector length. Expected ${tableSize}, got ${vector.length}.`; + } + if (data.obsolete !== undefined && typeof data.obsolete !== "boolean") { + return "Invalid type for obsolete. Must be a boolean."; + } + return null; +}; + +export const embeddingInputProcessing: ItemProcessor< + ApiInputEmbeddingItem, + ContentEmbeddingDataInput +> = (data) => { + const error = embeddingInputValidation(data); + if (error) { + return { valid: false, error }; + } + return { + valid: true, + processedItem: { ...data, vector: JSON.stringify(data.vector) }, + }; +}; + +export const embeddingOutputValidation: ItemValidator< + ApiOutputEmbeddingRecord +> = (data) => { + const { model, vector } = data; + if ( + !model || + typeof model !== "string" || + KNOWN_EMBEDDING_TABLES[model] == undefined + ) { + return "Missing or invalid model name."; + } + + const { tableSize } = KNOWN_EMBEDDING_TABLES[model]; + + if (vector.length !== tableSize) { + return `Invalid vector length. Expected ${tableSize}, got ${vector.length}.`; + } + return null; +}; + +export const embeddingOutputProcessing: ItemProcessor< + ContentEmbeddingRecord, + ApiOutputEmbeddingRecord +> = (data) => { + try { + const processedData = { ...data, vector: JSON.parse(data.vector) }; + const error = embeddingOutputValidation(processedData); + if (error) { + return { valid: false, error }; + } + return { + valid: true, + processedItem: processedData, + }; + } catch (error) { + if (error instanceof SyntaxError) { + return { valid: false, error: error.message }; + } + throw error; + } +}; + +export type ContentDataInput = TablesInsert<"Content">; +export type ContentRecord = Tables<"Content">; + +export const contentInputValidation: ItemValidator = ( + data: ContentDataInput, +) => { + if (!data || typeof data !== "object") + return "Invalid request body: expected a JSON object."; + const { author_id, created, last_modified, scale, space_id, text } = data; + + if (!text || typeof text !== "string") return "Invalid or missing text."; + if (!scale || typeof scale !== "string") return "Invalid or missing scale."; + if ( + space_id === undefined || + space_id === null || + typeof space_id !== "number" + ) + return "Invalid or missing space_id."; + if ( + author_id === undefined || + author_id === null || + typeof author_id !== "number" + ) + return "Invalid or missing author_id."; + if (created) + try { + new Date(created); + } catch (e) { + return "Invalid date format for created."; + } + if (last_modified) + try { + new Date(last_modified); + } catch (e) { + return "Invalid date format for last_modified."; + } + return null; +}; diff --git a/apps/website/package.json b/apps/website/package.json index 95e1e794d..31205f20a 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -14,8 +14,10 @@ "@repo/types": "*", "@repo/ui": "*", "@sindresorhus/slugify": "^2.2.1", + "@supabase/ssr": "^0.6.1", "gray-matter": "^4.0.3", "next": "^15.0.3", + "openai": "^4.98.0", "react": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106", "rehype-parse": "^9.0.1", @@ -27,6 +29,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@repo/database": "*", "@repo/eslint-config": "*", "@repo/tailwind-config": "*", "@repo/typescript-config": "*", diff --git a/package-lock.json b/package-lock.json index 87d8011db..70907c424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4308,10 +4308,6 @@ "license": "MIT", "peer": true }, - "apps/roam/node_modules/asynckit": { - "version": "0.4.0", - "license": "MIT" - }, "apps/roam/node_modules/aws-sdk": { "version": "2.1359.0", "license": "Apache-2.0", @@ -4536,16 +4532,6 @@ "version": "1.1.4", "license": "MIT" }, - "apps/roam/node_modules/combined-stream": { - "version": "1.0.8", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "apps/roam/node_modules/comma-separated-tokens": { "version": "1.0.8", "license": "MIT", @@ -4822,13 +4808,6 @@ "version": "4.0.1", "license": "ISC" }, - "apps/roam/node_modules/delayed-stream": { - "version": "1.0.0", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "apps/roam/node_modules/detect-node-es": { "version": "1.1.0", "license": "MIT" @@ -5084,18 +5063,6 @@ "node": ">= 8" } }, - "apps/roam/node_modules/form-data": { - "version": "4.0.0", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "apps/roam/node_modules/fs-constants": { "version": "1.0.0", "license": "MIT", @@ -5696,23 +5663,6 @@ "react": "^16.8.0 || >=17.0.0" } }, - "apps/roam/node_modules/mime-db": { - "version": "1.52.0", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "apps/roam/node_modules/mime-types": { - "version": "2.1.35", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "apps/roam/node_modules/minimatch": { "version": "3.1.2", "license": "ISC", @@ -6799,26 +6749,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "apps/roam/node_modules/ws": { - "version": "8.14.2", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "apps/roam/node_modules/xdg-basedir": { "version": "4.0.0", "license": "MIT", @@ -6963,8 +6893,10 @@ "@repo/types": "*", "@repo/ui": "*", "@sindresorhus/slugify": "^2.2.1", + "@supabase/ssr": "^0.6.1", "gray-matter": "^4.0.3", "next": "^15.0.3", + "openai": "^4.98.0", "react": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106", "rehype-parse": "^9.0.1", @@ -6976,6 +6908,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@repo/database": "*", "@repo/eslint-config": "*", "@repo/tailwind-config": "*", "@repo/typescript-config": "*", @@ -9101,6 +9034,99 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@supabase/auth-js": { + "version": "2.69.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz", + "integrity": "sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", + "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", + "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz", + "integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14", + "@types/phoenix": "^1.5.4", + "@types/ws": "^8.5.10", + "ws": "^8.18.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.6.1.tgz", + "integrity": "sha512-QtQgEMvaDzr77Mk3vZ3jWg2/y+D8tExYF7vcJT+wQ8ysuvOeGGjYbZlvj5bHYsj/SpC0bihcisnwPrM4Gp5G4g==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.43.4" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", + "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.49.8", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.8.tgz", + "integrity": "sha512-zzBQLgS/jZs7btWcIAc7V5yfB+juG7h0AXxKowMJuySsO5vK+F7Vp+HCa07Z+tu9lZtr3sT9fofkc86bdylmtw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@supabase/auth-js": "2.69.1", + "@supabase/functions-js": "2.4.4", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.19.4", + "@supabase/realtime-js": "2.11.2", + "@supabase/storage-js": "2.7.1" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -9357,6 +9383,16 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -9364,6 +9400,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT", + "peer": true + }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -9430,6 +9473,16 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -9926,6 +9979,18 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -9973,6 +10038,18 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -10280,6 +10357,12 @@ "retry": "0.13.1" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -10956,6 +11039,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -11009,6 +11104,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/core-js": { "version": "3.39.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", @@ -11297,6 +11401,15 @@ "node": ">=8" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -11694,15 +11807,15 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -12910,6 +13023,15 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -13186,6 +13308,49 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -14023,6 +14188,15 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -15895,6 +16069,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -16146,7 +16341,6 @@ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", - "dev": true, "funding": [ { "type": "github", @@ -16620,6 +16814,71 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "4.103.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.103.0.tgz", + "integrity": "sha512-eWcz9kdurkGOFDtd5ySS5y251H2uBgq9+1a2lTBnjMMzlexJ40Am5t6Mu76SSE87VvitPa0dkIAp75F+dZVC0g==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.103", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.103.tgz", + "integrity": "sha512-hHTHp+sEz6SxFsp+SA+Tqrua3AbmlAw+Y//aEwdHrdZkYVRWdvWD3y5uPZ0flYOkgskaFWqZ/YGFm3FaFQ0pRw==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -19155,6 +19414,12 @@ "node": ">=12" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -19874,10 +20139,26 @@ "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/website": { "resolved": "apps/website", "link": true }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -20125,6 +20406,28 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/packages/database/package.json b/packages/database/package.json index ed9a6d153..c2c930638 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -14,7 +14,7 @@ "check-types": "npm run lint && supabase stop && npm run dbdiff", "lint": "tsx scripts/lint.ts", "lint:fix": "tsx scripts/lint.ts -f", - "build": "supabase start && supabase migrations up && (supabase gen types typescript --local --schema public > types.gen.ts) && cp ./types.gen.ts ../../apps/website/app/utils/supabase", + "build": "if [ $HOME != '/vercel' ]; then\n supabase start && supabase migrations up && (supabase gen types typescript --local --schema public > types.gen.ts)\nfi", "gentypes:production": "supabase start && supabase gen types typescript --project-id \"$SUPABASE_PROJECT_ID\" --schema public > types.gen.ts", "dbdiff": "supabase stop && supabase db diff", "dbdiff:save": "supabase stop && supabase db diff -f", diff --git a/packages/database/types.gen.ts b/packages/database/types.gen.ts index a3981b363..990aa4b6a 100644 --- a/packages/database/types.gen.ts +++ b/packages/database/types.gen.ts @@ -11,30 +11,33 @@ export type Database = { Tables: { Account: { Row: { + account_local_id: string active: boolean + agent_id: number id: number - person_id: number platform_id: number write_permission: boolean } Insert: { + account_local_id: string active?: boolean + agent_id: number id?: number - person_id: number platform_id: number write_permission: boolean } Update: { + account_local_id?: string active?: boolean + agent_id?: number id?: number - person_id?: number platform_id?: number write_permission?: boolean } Relationships: [ { - foreignKeyName: "Account_person_id_fkey" - columns: ["person_id"] + foreignKeyName: "Account_agent_id_fkey" + columns: ["agent_id"] isOneToOne: false referencedRelation: "Agent" referencedColumns: ["id"] @@ -69,21 +72,21 @@ export type Database = { id: number metadata: Json name: string - version: string | null + version: string } Insert: { deterministic?: boolean | null id: number metadata?: Json name: string - version?: string | null + version: string } Update: { deterministic?: boolean | null id?: number metadata?: Json name?: string - version?: string | null + version?: string } Relationships: [ { @@ -109,7 +112,7 @@ export type Database = { name: string represented_by_id: number | null schema_id: number | null - space_id: number | null + space_id: number } Insert: { arity?: number @@ -124,7 +127,7 @@ export type Database = { name: string represented_by_id?: number | null schema_id?: number | null - space_id?: number | null + space_id: number } Update: { arity?: number @@ -139,7 +142,7 @@ export type Database = { name?: string represented_by_id?: number | null schema_id?: number | null - space_id?: number | null + space_id?: number } Relationships: [ { @@ -445,19 +448,19 @@ export type Database = { id: number name: string platform_id: number - url: string | null + url: string } Insert: { id?: number name: string platform_id: number - url?: string | null + url: string } Update: { id?: number name?: string platform_id?: number - url?: string | null + url?: string } Relationships: [ { @@ -473,20 +476,17 @@ export type Database = { Row: { account_id: number editor: boolean - id: number - space_id: number | null + space_id: number } Insert: { account_id: number editor: boolean - id?: number - space_id?: number | null + space_id: number } Update: { account_id?: number editor?: boolean - id?: number - space_id?: number | null + space_id?: number } Relationships: [ { diff --git a/turbo.json b/turbo.json index c52dabc35..0c423c56f 100644 --- a/turbo.json +++ b/turbo.json @@ -8,6 +8,7 @@ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", + "NODE_ENV", "BLOB_READ_WRITE_TOKEN" ], "dependsOn": ["^build"], @@ -21,7 +22,7 @@ "dependsOn": ["^check-types"] }, "dev": { - "passThroughEnv": ["OBSIDIAN_PLUGIN_PATH"], + "passThroughEnv": ["OBSIDIAN_PLUGIN_PATH", "NODE_ENV"], "cache": false, "persistent": true, "inputs": ["$TURBO_DEFAULT$", ".env*"] @@ -33,6 +34,7 @@ "BLOB_READ_WRITE_TOKEN", "GITHUB_REF_NAME", "GITHUB_HEAD_REF", + "NODE_ENV", "SUPABASE_PROJECT_ID", "SUPABASE_DB_PASSWORD", "SUPABASE_ACCESS_TOKEN" @@ -41,7 +43,12 @@ "publish": { "cache": false, "inputs": ["$TURBO_DEFAULT$", ".env*"], - "passThroughEnv": ["GITHUB_TOKEN", "APP_PRIVATE_KEY", "APP_ID"] + "passThroughEnv": [ + "GITHUB_TOKEN", + "APP_PRIVATE_KEY", + "APP_ID", + "NODE_ENV" + ] } } }