Feat: ingest api

This commit is contained in:
Harshith Mullapudi 2025-06-04 15:03:06 +05:30
parent dcf10aeb71
commit 8312cc342d
22 changed files with 741 additions and 200 deletions

View File

@ -1,45 +1,14 @@
import { useEffect, useState } from "react";
import { Paragraph } from "../ui/Paragraph";
import { Header3 } from "../ui/Headers";
import { Button } from "../ui";
import Logo from "../logo/logo";
import { Theme, useTheme } from "remix-themes";
interface QuoteType {
quote: string;
}
const quotes: QuoteType[] = [
{
quote:
"Recall remembers that I prefer emails in dark mode and hate promotional content. It automatically filters and formats my communications just the way I like.",
},
{
quote:
"When I mention liking Nike's latest running shoes, Recall remembers this preference and helps surface relevant product launches and deals across my browsing.",
},
{
quote:
"Echo knows I'm a vegetarian and helps filter restaurant recommendations and recipes accordingly, without me having to specify it every time.",
},
{
quote:
"By remembering that I prefer technical documentation with code examples, Echo helps prioritize learning resources that match my learning style.",
},
];
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
useEffect(() => {
const randomIndex = Math.floor(Math.random() * quotes.length);
setRandomQuote(quotes[randomIndex]);
}, []);
const [, setTheme] = useTheme();
return (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<div className="pt-8">
<Logo width={20} height={20} />
<Logo width={5} height={5} />
<Button onClick={() => setTheme(Theme.DARK)}>theme</Button>
</div>

View File

@ -0,0 +1,13 @@
import { cn } from "~/lib/utils";
export function Fieldset({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-y-5", className)}>{children}</div>
);
}

View File

@ -1,5 +1,5 @@
import type { Prisma, User } from "@recall/database";
import type { GoogleProfile } from "remix-auth-google";
import type { GoogleProfile } from "@coji/remix-auth-google";
import { prisma } from "~/db.server";
export type { User } from "@recall/database";

View File

@ -0,0 +1,26 @@
import { redirect, type LoaderFunction } from "@remix-run/node";
import { authenticator } from "~/services/auth.server";
import { redirectCookie } from "./auth.google";
import { logger } from "~/services/logger.service";
import { saveSession } from "~/services/sessionStorage.server";
export let loader: LoaderFunction = async ({ request }) => {
const cookie = request.headers.get("Cookie");
const redirectValue = await redirectCookie.parse(cookie);
const redirectTo = redirectValue ?? "/";
logger.debug("auth.google.callback loader", {
redirectTo,
});
const authuser = await authenticator.authenticate("google", request);
const headers = await saveSession(request, authuser);
logger.debug("auth.google.callback authuser", {
authuser,
});
return redirect(redirectTo, {
headers,
});
};

View File

@ -0,0 +1,39 @@
import {
redirect,
createCookie,
type ActionFunction,
type LoaderFunction,
} from "@remix-run/node";
import { authenticator } from "~/services/auth.server";
export let loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirectTo");
try {
// call authenticate as usual, in successRedirect use returnTo or a fallback
const rf = await authenticator.authenticate("google", request);
console.log(rf);
return rf;
} catch (error) {
// here we catch anything authenticator.authenticate throw, this will
// include redirects
// if the error is a Response and is a redirect
if (error instanceof Response) {
// we need to append a Set-Cookie header with a cookie storing the
// returnTo value
error.headers.append(
"Set-Cookie",
await redirectCookie.serialize(redirectTo),
);
}
throw error;
}
};
export const redirectCookie = createCookie("redirect-to", {
maxAge: 60 * 60, // 1 hour
httpOnly: true,
});

View File

@ -1,31 +1,71 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import { useNavigation } from "@remix-run/react";
import { typedjson } from "remix-typedjson";
import { Form } from "@remix-run/react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
import { authenticator } from "~/services/auth.server";
import {
commitSession,
getUserSession,
} from "~/services/sessionStorage.server";
import { Fieldset } from "~/components/ui/Fieldset";
import { Header1 } from "~/components/ui/Headers";
import { Paragraph } from "~/components/ui/Paragraph";
import { isGoogleAuthSupported } from "~/services/auth.server";
import { setRedirectTo } from "~/services/redirectTo.server";
import { getUserId } from "~/services/session.server";
import { commitSession } from "~/services/sessionStorage.server";
import { requestUrl } from "~/utils/requestUrl.server";
import { RiGoogleLine } from "@remixicon/react";
export async function loader({ request }: LoaderFunctionArgs) {
await authenticator.isAuthenticated(request, {
successRedirect: "/",
});
const userId = await getUserId(request);
if (userId) return redirect("/");
const session = await getUserSession(request);
const url = requestUrl(request);
const redirectTo = url.searchParams.get("redirectTo");
return typedjson({
headers: { "Set-Cookie": await commitSession(session) },
});
if (redirectTo) {
const session = await setRedirectTo(request, redirectTo);
return typedjson(
{ redirectTo, showGoogleAuth: isGoogleAuthSupported },
{
headers: {
"Set-Cookie": await commitSession(session),
},
},
);
} else {
return typedjson({
redirectTo: null,
showGoogleAuth: isGoogleAuthSupported,
});
}
}
export default function LoginPage() {
const navigate = useNavigation();
const data = useTypedLoaderData<typeof loader>();
return (
<LoginPageLayout>
<h2>Lohin</h2>
</LoginPageLayout>
<Form
action={`/auth/google${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
method="GET"
className="w-full"
>
<div className="flex flex-col items-center">
<Header1 className="pb-4 font-semibold sm:text-2xl md:text-3xl lg:text-4xl">
Welcome
</Header1>
<Paragraph variant="base" className="mb-6">
Create an account or login
</Paragraph>
<Fieldset className="w-full">
<div className="flex flex-col gap-y-2">
{data.showGoogleAuth && (
<button type="submit" data-action="continue with google">
<RiGoogleLine className={"mr-2 size-5"} />
<span className="text-text-bright">Continue with Google</span>
</button>
)}
</div>
</Fieldset>
</div>
</Form>
);
}

View File

@ -9,7 +9,7 @@ const ParamsSchema = z.object({
export const IngestBodyRequest = z.object({
name: z.string(),
episodeBody: z.string(),
referenceTime: z.date(),
referenceTime: z.string(),
type: z.enum(["CONVERSATION", "TEXT"]), // Assuming these are the EpisodeType values
source: z.string(),
userId: z.string(),

View File

@ -2,19 +2,23 @@ import { Authenticator } from "remix-auth";
import type { AuthUser } from "./authUser";
import { addGoogleStrategy } from "./googleAuth.server";
import { sessionStorage } from "./sessionStorage.server";
import { env } from "~/env.server";
// Create an instance of the authenticator, pass a generic with what
// strategies will return and will store in the session
const authenticator = new Authenticator<AuthUser>(sessionStorage);
const authenticator = new Authenticator<AuthUser>();
const isGoogleAuthSupported =
typeof env.AUTH_GOOGLE_CLIENT_ID === "string" &&
typeof env.AUTH_GOOGLE_CLIENT_SECRET === "string";
if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
addGoogleStrategy(authenticator, env.AUTH_GOOGLE_CLIENT_ID, env.AUTH_GOOGLE_CLIENT_SECRET);
addGoogleStrategy(
authenticator,
env.AUTH_GOOGLE_CLIENT_ID,
env.AUTH_GOOGLE_CLIENT_SECRET,
);
}
export { authenticator, isGoogleAuthSupported };

View File

@ -1,5 +1,5 @@
import type { Authenticator } from "remix-auth";
import { GoogleStrategy } from "remix-auth-google";
import { GoogleStrategy } from "@coji/remix-auth-google";
import { env } from "~/env.server";
import { findOrCreateUser } from "~/models/user.server";
import type { AuthUser } from "./authUser";
@ -8,34 +8,36 @@ import { logger } from "./logger.service";
export function addGoogleStrategy(
authenticator: Authenticator<AuthUser>,
clientID: string,
clientSecret: string
clientId: string,
clientSecret: string,
) {
const googleStrategy = new GoogleStrategy(
{
clientID,
clientId,
clientSecret,
callbackURL: `${env.LOGIN_ORIGIN}/auth/google/callback`,
redirectURI: `${env.LOGIN_ORIGIN}/auth/google/callback`,
},
async ({ extraParams, profile }) => {
async ({ tokens }) => {
const profile = await GoogleStrategy.userProfile(tokens);
const emails = profile.emails;
if (!emails) {
throw new Error("Google login requires an email address");
}
console.log(tokens);
try {
logger.debug("Google login", {
emails,
profile,
extraParams,
});
const { user, isNewUser } = await findOrCreateUser({
email: emails[0].value,
authenticationMethod: "GOOGLE",
authenticationProfile: profile,
authenticationExtraParams: extraParams,
authenticationExtraParams: {},
});
await postAuthentication({ user, isNewUser, loginMethod: "GOOGLE" });
@ -47,8 +49,8 @@ export function addGoogleStrategy(
console.error(error);
throw error;
}
}
},
);
authenticator.use(googleStrategy);
authenticator.use(googleStrategy as any, "google");
}

View File

@ -0,0 +1,373 @@
import { type PersonalAccessToken } from "@recall/database";
import { customAlphabet, nanoid } from "nanoid";
import nodeCrypto from "node:crypto";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "./logger.service";
const tokenValueLength = 40;
//lowercase only, removed 0 and l to avoid confusion
const tokenGenerator = customAlphabet(
"123456789abcdefghijkmnopqrstuvwxyz",
tokenValueLength,
);
type CreatePersonalAccessTokenOptions = {
name: string;
userId: string;
};
/** Returns obfuscated access tokens that aren't revoked */
export async function getValidPersonalAccessTokens(userId: string) {
const personalAccessTokens = await prisma.personalAccessToken.findMany({
select: {
id: true,
name: true,
obfuscatedToken: true,
createdAt: true,
lastAccessedAt: true,
},
where: {
userId,
revokedAt: null,
},
});
return personalAccessTokens.map((pat) => ({
id: pat.id,
name: pat.name,
obfuscatedToken: pat.obfuscatedToken,
createdAt: pat.createdAt,
lastAccessedAt: pat.lastAccessedAt,
}));
}
export type ObfuscatedPersonalAccessToken = Awaited<
ReturnType<typeof getValidPersonalAccessTokens>
>[number];
/** Gets a PersonalAccessToken from an Auth Code, this only works within 10 mins of the auth code being created */
export async function getPersonalAccessTokenFromAuthorizationCode(
authorizationCode: string,
) {
//only allow authorization codes that were created less than 10 mins ago
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const code = await prisma.authorizationCode.findUnique({
select: {
personalAccessToken: true,
},
where: {
code: authorizationCode,
createdAt: {
gte: tenMinutesAgo,
},
},
});
if (!code) {
throw new Error("Invalid authorization code, or code expired");
}
//there's no PersonalAccessToken associated with this code
if (!code.personalAccessToken) {
return {
token: null,
};
}
const decryptedToken = decryptPersonalAccessToken(code.personalAccessToken);
return {
token: {
token: decryptedToken,
obfuscatedToken: code.personalAccessToken.obfuscatedToken,
},
};
}
export async function revokePersonalAccessToken(tokenId: string) {
await prisma.personalAccessToken.update({
where: {
id: tokenId,
},
data: {
revokedAt: new Date(),
},
});
}
export type PersonalAccessTokenAuthenticationResult = {
userId: string;
};
const EncryptedSecretValueSchema = z.object({
nonce: z.string(),
ciphertext: z.string(),
tag: z.string(),
});
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
export async function authenticateApiRequestWithPersonalAccessToken(
request: Request,
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
const token = getPersonalAccessTokenFromRequest(request);
if (!token) {
return;
}
return authenticatePersonalAccessToken(token);
}
function getPersonalAccessTokenFromRequest(request: Request) {
const rawAuthorization = request.headers.get("Authorization");
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
if (!authorization.success) {
return;
}
const personalAccessToken = authorization.data.replace(/^Bearer /, "");
return personalAccessToken;
}
export async function authenticatePersonalAccessToken(
token: string,
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
if (!token.startsWith(tokenPrefix)) {
logger.warn(`PAT doesn't start with ${tokenPrefix}`);
return;
}
const hashedToken = hashToken(token);
const personalAccessToken = await prisma.personalAccessToken.findFirst({
where: {
hashedToken,
revokedAt: null,
},
});
if (!personalAccessToken) {
// The token may have been revoked or is entirely invalid
return;
}
await prisma.personalAccessToken.update({
where: {
id: personalAccessToken.id,
},
data: {
lastAccessedAt: new Date(),
},
});
const decryptedToken = decryptPersonalAccessToken(personalAccessToken);
if (decryptedToken !== token) {
logger.error(
`PersonalAccessToken with id: ${personalAccessToken.id} was found in the database with hash ${hashedToken}, but the decrypted token did not match the provided token.`,
);
return;
}
return {
userId: personalAccessToken.userId,
};
}
export function isPersonalAccessToken(token: string) {
return token.startsWith(tokenPrefix);
}
export function createAuthorizationCode() {
return prisma.authorizationCode.create({
data: {
code: nanoid(64),
},
});
}
/** Creates a PersonalAccessToken from an Auth Code, and return the token. We only ever return the unencrypted token once. */
export async function createPersonalAccessTokenFromAuthorizationCode(
authorizationCode: string,
userId: string,
) {
//only allow authorization codes that were created less than 10 mins ago
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const code = await prisma.authorizationCode.findUnique({
where: {
code: authorizationCode,
personalAccessTokenId: null,
createdAt: {
gte: tenMinutesAgo,
},
},
});
if (!code) {
throw new Error(
"Invalid authorization code, code already used, or code expired",
);
}
const existingCliPersonalAccessToken =
await prisma.personalAccessToken.findFirst({
where: {
userId,
name: "cli",
},
});
//we only allow you to have one CLI PAT at a time, so return this
if (existingCliPersonalAccessToken) {
//associate this authorization code with the existing personal access token
await prisma.authorizationCode.update({
where: {
code: authorizationCode,
},
data: {
personalAccessTokenId: existingCliPersonalAccessToken.id,
},
});
if (existingCliPersonalAccessToken.revokedAt) {
// re-activate revoked CLI PAT so we can use it again
await prisma.personalAccessToken.update({
where: {
id: existingCliPersonalAccessToken.id,
},
data: {
revokedAt: null,
},
});
}
//we don't return the decrypted token
return {
id: existingCliPersonalAccessToken.id,
name: existingCliPersonalAccessToken.name,
userId: existingCliPersonalAccessToken.userId,
obfuscateToken: existingCliPersonalAccessToken.obfuscatedToken,
};
}
const token = await createPersonalAccessToken({
name: "cli",
userId,
});
await prisma.authorizationCode.update({
where: {
code: authorizationCode,
},
data: {
personalAccessTokenId: token.id,
},
});
return token;
}
/** Created a new PersonalAccessToken, and return the token. We only ever return the unencrypted token once. */
export async function createPersonalAccessToken({
name,
userId,
}: CreatePersonalAccessTokenOptions) {
const token = createToken();
const encryptedToken = encryptToken(token);
const personalAccessToken = await prisma.personalAccessToken.create({
data: {
name,
userId,
encryptedToken,
obfuscatedToken: obfuscateToken(token),
hashedToken: hashToken(token),
},
});
return {
id: personalAccessToken.id,
name,
userId,
token,
obfuscatedToken: personalAccessToken.obfuscatedToken,
};
}
export type CreatedPersonalAccessToken = Awaited<
ReturnType<typeof createPersonalAccessToken>
>;
const tokenPrefix = "rc_pat_";
/** Creates a PersonalAccessToken that starts with tr_pat_ */
function createToken() {
return `${tokenPrefix}${tokenGenerator()}`;
}
/** Obfuscates all but the first and last 4 characters of the token, so it looks like tr_pat_bhbd•••••••••••••••••••fd4a */
function obfuscateToken(token: string) {
const withoutPrefix = token.replace(tokenPrefix, "");
const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`;
return `${tokenPrefix}${obfuscated}`;
}
function encryptToken(value: string) {
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv(
"aes-256-gcm",
env.ENCRYPTION_KEY,
nonce,
);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag,
};
}
function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
const encryptedData = EncryptedSecretValueSchema.safeParse(
personalAccessToken.encryptedToken,
);
if (!encryptedData.success) {
throw new Error(
`Unable to parse encrypted PersonalAccessToken with id: ${personalAccessToken.id}: ${encryptedData.error.message}`,
);
}
const decryptedToken = decryptToken(
encryptedData.data.nonce,
encryptedData.data.ciphertext,
encryptedData.data.tag,
);
return decryptedToken;
}
function decryptToken(nonce: string, ciphertext: string, tag: string): string {
const decipher = nodeCrypto.createDecipheriv(
"aes-256-gcm",
env.ENCRYPTION_KEY,
Buffer.from(nonce, "hex"),
);
decipher.setAuthTag(Buffer.from(tag, "hex"));
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
function hashToken(token: string): string {
const hash = nodeCrypto.createHash("sha256");
hash.update(token);
return hash.digest("hex");
}

View File

@ -0,0 +1,51 @@
import { createCookieSessionStorage } from "@remix-run/node";
import { z } from "zod";
import { env } from "~/env.server";
const ONE_DAY = 60 * 60 * 24;
export const { commitSession, getSession } = createCookieSessionStorage({
cookie: {
name: "__redirectTo",
path: "/",
httpOnly: true,
sameSite: "lax",
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production",
maxAge: ONE_DAY,
},
});
export function getRedirectSession(request: Request) {
return getSession(request.headers.get("Cookie"));
}
export async function setRedirectTo(request: Request, redirectTo: string) {
const session = await getRedirectSession(request);
if (session) {
session.set("redirectTo", redirectTo);
}
return session;
}
export async function clearRedirectTo(request: Request) {
const session = await getRedirectSession(request);
if (session) {
session.unset("redirectTo");
}
return session;
}
export async function getRedirectTo(
request: Request,
): Promise<string | undefined> {
const session = await getRedirectSession(request);
if (session) {
return z.string().optional().parse(session.get("redirectTo"));
}
}

View File

@ -1,6 +1,6 @@
import { redirect } from "@remix-run/node";
import { getUserById } from "~/models/user.server";
import { authenticator } from "./auth.server";
import { sessionStorage } from "./sessionStorage.server";
import { getImpersonationId } from "./impersonation.server";
export async function getUserId(request: Request): Promise<string | undefined> {
@ -8,8 +8,10 @@ export async function getUserId(request: Request): Promise<string | undefined> {
if (impersonatedUserId) return impersonatedUserId;
let authUser = await authenticator.isAuthenticated(request);
return authUser?.userId;
let session = await sessionStorage.getSession(request.headers.get("cookie"));
let user = session.get("user");
return user?.userId;
}
export async function getUser(request: Request) {

View File

@ -1,8 +1,13 @@
import { createCookieSessionStorage } from "@remix-run/node";
import { createThemeSessionResolver } from "remix-themes";
import { env } from "~/env.server";
import { type AuthUser } from "./authUser";
export const sessionStorage = createCookieSessionStorage({
let SESSION_KEY = "user";
export const sessionStorage = createCookieSessionStorage<{
[SESSION_KEY]: AuthUser;
}>({
cookie: {
name: "__session", // use any name you want here
sameSite: "lax", // this helps with CSRF
@ -26,6 +31,18 @@ export const themeStorage = createCookieSessionStorage({
},
});
export const getSessionFromStore = async (request: Request) => {
return await sessionStorage.getSession(request.headers.get("Cookie"));
};
export const saveSession = async (request: Request, user: AuthUser) => {
const session = await getSessionFromStore(request);
session.set(SESSION_KEY, user);
return new Headers({
"Set-Cookie": await sessionStorage.commitSession(session),
});
};
export const themeSessionResolver = createThemeSessionResolver(sessionStorage);
export function getUserSession(request: Request) {

View File

@ -0,0 +1,10 @@
// Updates the protocol of the request url to match the request.headers x-forwarded-proto
export function requestUrl(request: Request): URL {
const url = new URL(request.url);
if (request.headers.get("x-forwarded-proto") === "https") {
url.protocol = "https:";
}
return url;
}

View File

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"build": "remix vite:build",
"dev": "node ./server.js",
"dev": "node ./server.mjs",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"typecheck": "tsc"
@ -41,8 +41,10 @@
"posthog-js": "^1.116.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remix-auth": "^3.6.0",
"remix-auth-google": "^2.0.0",
"@remixicon/react": "^4.2.0",
"remix-auth": "^4.2.0",
"@coji/remix-auth-google": "^4.2.0",
"remix-auth-oauth2": "^3.4.1",
"remix-themes": "^1.3.1",
"remix-typedjson": "0.3.1",
"remix-utils": "^7.7.0",

View File

@ -23,4 +23,8 @@ export default defineConfig({
}),
tsconfigPaths(),
],
server: {
allowedHosts: true,
port: 3033,
},
});

View File

@ -1,12 +0,0 @@
{
"vector_config": {
"m": 16,
"ef_construction": 128,
"ef_search": 768
},
"graph_config": {
"secondary_indices": []
},
"db_max_size_gb": 10
}

View File

@ -1,26 +0,0 @@
// Start writing your queries here.
//
// You can use the schema to help you write your queries.
//
// Queries take the form:
// QUERY {query name}({input name}: {input type}) =>
// {variable} <- {traversal}
// RETURN {variable}
//
// Example:
// QUERY GetUserFriends(user_id: String) =>
// friends <- N<User>(user_id)::Out<Knows>
// RETURN friends
//
//
// For more information on how to write queries,
// see the documentation at https://docs.helix-db.com
// or checkout our GitHub at https://github.com/HelixDB/helix-db
QUERY hnswinsert(vector: [F64]) =>
AddV<Embedding>(vector)
RETURN "Success"
QUERY hnswsearch(query: [F64], k: I32) =>
res <- SearchV<Embedding>(query, k)
RETURN res

View File

@ -1,38 +0,0 @@
// Start building your schema here.
//
// The schema is used to to ensure a level of type safety in your queries.
//
// The schema is made up of Node types, denoted by N::,
// and Edge types, denoted by E::
//
// Under the Node types you can define fields that
// will be stored in the database.
//
// Under the Edge types you can define what type of node
// the edge will connect to and from, and also the
// properties that you want to store on the edge.
//
// Example:
//
// N::User {
// Name: String,
// Label: String,
// Age: Integer,
// IsAdmin: Boolean,
// }
//
// E::Knows {
// From: User,
// To: User,
// Properties: {
// Since: Integer,
// }
// }
//
// For more information on how to write queries,
// see the documentation at https://docs.helix-db.com
// or checkout our GitHub at https://github.com/HelixDB/helix-db
V::Embedding {
vec: [F64]
}

View File

@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "Workspace" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deleted" TIMESTAMP(3),
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"icon" TEXT,
"userId" TEXT,
CONSTRAINT "Workspace_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Workspace_slug_key" ON "Workspace"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Workspace_userId_key" ON "Workspace"("userId");
-- AddForeignKey
ALTER TABLE "Workspace" ADD CONSTRAINT "Workspace_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

147
pnpm-lock.yaml generated
View File

@ -33,6 +33,9 @@ importers:
'@ai-sdk/openai':
specifier: ^1.3.21
version: 1.3.22(zod@3.23.8)
'@coji/remix-auth-google':
specifier: ^4.2.0
version: 4.2.0
'@opentelemetry/api':
specifier: 1.9.0
version: 1.9.0
@ -66,6 +69,9 @@ importers:
'@remix-run/v1-meta':
specifier: ^0.1.3
version: 0.1.3(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3))
'@remixicon/react':
specifier: ^4.2.0
version: 4.6.0(react@18.3.1)
'@tailwindcss/container-queries':
specifier: ^0.1.1
version: 0.1.1(tailwindcss@4.1.7)
@ -121,11 +127,11 @@ importers:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
remix-auth:
specifier: ^3.6.0
version: 3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3))
remix-auth-google:
specifier: ^2.0.0
version: 2.0.0(@remix-run/server-runtime@2.16.7(typescript@5.8.3))(remix-auth@3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3)))
specifier: ^4.2.0
version: 4.2.0
remix-auth-oauth2:
specifier: ^3.4.1
version: 3.4.1(remix-auth@4.2.0)
remix-themes:
specifier: ^1.3.1
version: 1.6.1(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3))
@ -570,6 +576,13 @@ packages:
'@changesets/write@0.2.3':
resolution: {integrity: sha512-Dbamr7AIMvslKnNYsLFafaVORx4H0pvCA2MHqgtNCySMe1blImEyAEOzDmcgKAkgz4+uwoLz7demIrX+JBr/Xw==}
'@coji/remix-auth-google@4.2.0':
resolution: {integrity: sha512-H9i3fvVz0GE18GUZHpz7p7FQjuiuloTIBAPjW7cfv7lUEk+mI6WRTVLEHJBLLuTlAF1+0EbzvPRYKutxZiFdfw==}
'@edgefirst-dev/data@0.0.4':
resolution: {integrity: sha512-VLhlvEPDJ0Sd0pE6sAYTQkIqZCXVonaWlgRJIQQHzfjTXCadF77qqHj5NxaPSc4wCul0DJO/0MnejVqJAXUiRg==}
engines: {node: '>=20.0.0'}
'@emnapi/core@1.4.3':
resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==}
@ -1086,6 +1099,9 @@ packages:
'@mdx-js/mdx@2.3.0':
resolution: {integrity: sha512-jLuwRlz8DQfQNiUCJR50Y09CGPq3fLtmtUQfVrj79E0JWu3dvsVcxVIcfhR5h0iXu+/z++zDrYeiJqifRynJkA==}
'@mjackson/headers@0.10.0':
resolution: {integrity: sha512-U1Eu1gF979k7ZoIBsJyD+T5l9MjtPONsZfoXfktsQHPJD0s7SokBGx+tLKDLsOY+gzVYAWS0yRFDNY8cgbQzWQ==}
'@napi-rs/wasm-runtime@0.2.10':
resolution: {integrity: sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==}
@ -1128,6 +1144,24 @@ packages:
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@oslojs/asn1@1.0.0':
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
'@oslojs/binary@1.0.0':
resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==}
'@oslojs/crypto@1.0.1':
resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==}
'@oslojs/encoding@0.4.1':
resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==}
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
'@oslojs/jwt@0.2.0':
resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@ -1301,6 +1335,11 @@ packages:
'@remix-run/web-stream@1.1.0':
resolution: {integrity: sha512-KRJtwrjRV5Bb+pM7zxcTJkhIqWWSy+MYsIxHK+0m5atcznsf15YwUBWHWulZerV2+vvHH1Lp1DD7pw6qKW8SgA==}
'@remixicon/react@4.6.0':
resolution: {integrity: sha512-bY56maEgT5IYUSRotqy9h03IAKJC85vlKtWFg2FKzfs8JPrkdBAYSa9dxoUSKFwGzup8Ux6vjShs9Aec3jvr2w==}
peerDependencies:
react: '>=18.2.0'
'@rollup/rollup-android-arm-eabi@4.41.1':
resolution: {integrity: sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==}
cpu: [arm]
@ -1936,6 +1975,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
arctic@3.7.0:
resolution: {integrity: sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==}
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
@ -4752,23 +4794,15 @@ packages:
remark-rehype@10.1.0:
resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==}
remix-auth-google@2.0.0:
resolution: {integrity: sha512-qP38N1ZJADz+HlH2lrEn/rSL6m5Dwcnz8xUFDhbHecDMl9FU/UpkUx3w8jj5XhNSnkSue/GWT+p7OEkatgbXNA==}
remix-auth-oauth2@3.4.1:
resolution: {integrity: sha512-ZhGon1czdIsOw1/O9EcTCzapZB6FpT3u9vtXSVeEMwGNs+iWljRsibnUC1RtwJbzzCdLBSwIXTNTbiRmLZ4cZw==}
engines: {node: ^20.0.0 || >=20.0.0}
peerDependencies:
'@remix-run/server-runtime': ^2.0.1
remix-auth: ^3.2.1
remix-auth: ^4.0.0
remix-auth-oauth2@1.11.2:
resolution: {integrity: sha512-5ORP+LMi5CVCA/Wb8Z+FCAJ73Uiy4uyjEzhlVwNBfdAkPOnfxzoi+q/pY/CrueYv3OniCXRM35ZYqkVi3G1UPw==}
peerDependencies:
'@remix-run/server-runtime': ^1.0.0 || ^2.0.0
remix-auth: ^3.6.0
remix-auth@3.7.0:
resolution: {integrity: sha512-2QVjp2nJVaYxuFBecMQwzixCO7CLSssttLBU5eVlNcNlVeNMmY1g7OkmZ1Ogw9sBcoMXZ18J7xXSK0AISVFcfQ==}
peerDependencies:
'@remix-run/react': ^1.0.0 || ^2.0.0
'@remix-run/server-runtime': ^1.0.0 || ^2.0.0
remix-auth@4.2.0:
resolution: {integrity: sha512-3LSfWEvSgG2CgbG/p4ge5hbV8tTXWNnnYIGbTr9oSSiHz9dD7wh6S0MEyo3pwh7MlKezB2WIlevGeyqUZykk7g==}
engines: {node: '>=20.0.0'}
remix-themes@1.6.1:
resolution: {integrity: sha512-wqJyNKJ2hiOweycQzsAk7CZm+2mKNAbW2QZcX0riw52XepAxf9R2v8NYyeUz+uWmb3Fulyi71s4aipNRTxCysw==}
@ -5476,14 +5510,6 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
uvu@0.5.6:
resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==}
engines: {node: '>=8'}
@ -6205,6 +6231,10 @@ snapshots:
human-id: 1.0.2
prettier: 2.8.8
'@coji/remix-auth-google@4.2.0': {}
'@edgefirst-dev/data@0.0.4': {}
'@emnapi/core@1.4.3':
dependencies:
'@emnapi/wasi-threads': 1.0.2
@ -6549,6 +6579,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@mjackson/headers@0.10.0': {}
'@napi-rs/wasm-runtime@0.2.10':
dependencies:
'@emnapi/core': 1.4.3
@ -6609,6 +6641,25 @@ snapshots:
'@opentelemetry/api@1.9.0': {}
'@oslojs/asn1@1.0.0':
dependencies:
'@oslojs/binary': 1.0.0
'@oslojs/binary@1.0.0': {}
'@oslojs/crypto@1.0.1':
dependencies:
'@oslojs/asn1': 1.0.0
'@oslojs/binary': 1.0.0
'@oslojs/encoding@0.4.1': {}
'@oslojs/encoding@1.1.0': {}
'@oslojs/jwt@0.2.0':
dependencies:
'@oslojs/encoding': 0.4.1
'@pkgjs/parseargs@0.11.0':
optional: true
@ -6884,6 +6935,10 @@ snapshots:
dependencies:
web-streams-polyfill: 3.3.3
'@remixicon/react@4.6.0(react@18.3.1)':
dependencies:
react: 18.3.1
'@rollup/rollup-android-arm-eabi@4.41.1':
optional: true
@ -7534,6 +7589,12 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
arctic@3.7.0:
dependencies:
'@oslojs/crypto': 1.0.1
'@oslojs/encoding': 1.1.0
'@oslojs/jwt': 0.2.0
arg@5.0.2: {}
argparse@1.0.10:
@ -10750,28 +10811,14 @@ snapshots:
mdast-util-to-hast: 12.3.0
unified: 10.1.2
remix-auth-google@2.0.0(@remix-run/server-runtime@2.16.7(typescript@5.8.3))(remix-auth@3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3))):
remix-auth-oauth2@3.4.1(remix-auth@4.2.0):
dependencies:
'@remix-run/server-runtime': 2.16.7(typescript@5.8.3)
remix-auth: 3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3))
remix-auth-oauth2: 1.11.2(@remix-run/server-runtime@2.16.7(typescript@5.8.3))(remix-auth@3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3)))
transitivePeerDependencies:
- supports-color
'@edgefirst-dev/data': 0.0.4
'@mjackson/headers': 0.10.0
arctic: 3.7.0
remix-auth: 4.2.0
remix-auth-oauth2@1.11.2(@remix-run/server-runtime@2.16.7(typescript@5.8.3))(remix-auth@3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3))):
dependencies:
'@remix-run/server-runtime': 2.16.7(typescript@5.8.3)
debug: 4.4.1
remix-auth: 3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3))
uuid: 9.0.1
transitivePeerDependencies:
- supports-color
remix-auth@3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3)):
dependencies:
'@remix-run/react': 2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)
'@remix-run/server-runtime': 2.16.7(typescript@5.8.3)
uuid: 8.3.2
remix-auth@4.2.0: {}
remix-themes@1.6.1(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3)):
dependencies:
@ -11565,10 +11612,6 @@ snapshots:
utils-merge@1.0.1: {}
uuid@8.3.2: {}
uuid@9.0.1: {}
uvu@0.5.6:
dependencies:
dequal: 2.0.3