Skip to content

Commit be64ea7

Browse files
committed
auth and cloud sync work in progress
1 parent 8f71139 commit be64ea7

File tree

14 files changed

+223
-408
lines changed

14 files changed

+223
-408
lines changed

.drizzle/store.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@
1919
}
2020
]
2121
]
22-
}
22+
}

apps/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
"@supabase/supabase-js": "^2.58.0",
1414
"@t3-oss/env-core": "^0.13.8",
1515
"@tailwindcss/vite": "^4.1.14",
16+
"drizzle-orm": "^0.44.6",
1617
"hono": "^4.9.10",
18+
"postgres": "^3.4.7",
1719
"tailwindcss": "^4.1.14",
1820
"zod": "^4.1.12"
1921
},

apps/api/src/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const getEnv = (c: Context<Env>) =>
1212
OPENAI_DEFAULT_MODEL: z.string().min(1),
1313
OPENAI_BASE_URL: z.string().min(1),
1414
OPENAI_API_KEY: z.string().min(1),
15+
DATABASE_URL: z.string().min(1),
1516
SUPABASE_URL: z.string().min(1),
1617
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
1718
},

apps/api/src/index.tsx

Lines changed: 77 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import { zValidator } from "@hono/zod-validator";
2+
import { sql } from "drizzle-orm";
23
import { Hono } from "hono";
4+
import { logger } from "hono/logger";
35
import { proxy } from "hono/proxy";
46
import { z } from "zod";
57

68
import { getEnv } from "./env";
9+
import { drizzlePersisterMiddleware } from "./middleware/drizzle";
710
import { supabaseMiddleware } from "./middleware/supabase";
811
import { renderer } from "./renderer";
912
import type { Env } from "./types";
1013

1114
const app = new Hono<Env>();
15+
app.use(logger());
1216
app.use("/v1", supabaseMiddleware());
1317

1418
app.get("/health", (c) => c.text("OK"));
15-
app.get("/", renderer, (c) => {
19+
app.get("/callback/auth", renderer, (c) => {
1620
const params = c.req.query();
1721
const code = params.code;
1822
const deeplink = "hypr://auth/callback?" + new URLSearchParams(params).toString();
@@ -56,39 +60,87 @@ app.get("/", renderer, (c) => {
5660

5761
app.post(
5862
"/v1/write",
63+
drizzlePersisterMiddleware(),
5964
zValidator(
6065
"json",
61-
z.discriminatedUnion("operation", [
62-
z.object({
63-
table: z.string(),
64-
row_id: z.string(),
65-
operation: z.literal("delete"),
66-
}),
67-
z.object({
68-
table: z.string(),
69-
row_id: z.string(),
70-
data: z.record(z.string(), z.unknown()),
71-
operation: z.literal("update"),
72-
}),
73-
]),
66+
z.array(
67+
z.discriminatedUnion("operation", [
68+
z.object({
69+
table: z.string(),
70+
row_id: z.string(),
71+
operation: z.literal("delete"),
72+
}),
73+
z.object({
74+
table: z.string(),
75+
row_id: z.string(),
76+
data: z.record(z.string(), z.unknown()),
77+
operation: z.literal("update"),
78+
}),
79+
]),
80+
),
7481
),
7582
async (c) => {
76-
const supabase = c.get("supabase");
83+
const db = c.get("db");
7784
const user = c.get("user");
7885
const body = c.req.valid("json");
7986

80-
// TODO: use RPC / transaction
81-
if (body.operation === "delete") {
82-
await supabase.from(body.table).delete().eq("id", body.row_id);
83-
} else {
84-
await supabase.from(body.table).upsert({
85-
...body.data,
86-
id: body.row_id,
87-
user_id: user.id,
87+
try {
88+
await db.transaction(async (tx) => {
89+
for (const change of body) {
90+
const tableName = sql.identifier(change.table);
91+
92+
if (change.operation === "delete") {
93+
await tx.execute(
94+
sql`
95+
DELETE FROM ${tableName}
96+
WHERE id = ${change.row_id}
97+
AND user_id = ${user.id}
98+
`,
99+
);
100+
} else {
101+
const protectedFields = new Set(["id", "user_id"]);
102+
const safeData = Object.fromEntries(
103+
Object.entries(change.data).filter(([key]) => !protectedFields.has(key)),
104+
);
105+
106+
const columns = ["id", "user_id", ...Object.keys(safeData)];
107+
const values = [change.row_id, user.id, ...Object.values(safeData)];
108+
109+
const columnIdentifiers = sql.join(
110+
columns.map((col) => sql.identifier(col)),
111+
sql.raw(", "),
112+
);
113+
114+
const valuePlaceholders = sql.join(
115+
values.map((v) => sql`${v}`),
116+
sql.raw(", "),
117+
);
118+
119+
const updateSet = sql.join(
120+
columns.slice(2).map((col) => {
121+
const colId = sql.identifier(col);
122+
return sql`${colId} = EXCLUDED.${colId}`;
123+
}),
124+
sql.raw(", "),
125+
);
126+
127+
await tx.execute(
128+
sql`
129+
INSERT INTO ${tableName} (${columnIdentifiers})
130+
VALUES (${valuePlaceholders})
131+
ON CONFLICT (id)
132+
DO UPDATE SET ${updateSet}
133+
WHERE ${tableName}.user_id = ${user.id}
134+
`,
135+
);
136+
}
137+
}
88138
});
89-
}
90139

91-
return c.json({ message: "OK" });
140+
return c.json({ message: "OK" });
141+
} catch (error) {
142+
return c.json({ error }, 500);
143+
}
92144
},
93145
);
94146

apps/api/src/middleware/drizzle.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { drizzle } from "drizzle-orm/postgres-js";
2+
import { createMiddleware } from "hono/factory";
3+
4+
import postgres from "postgres";
5+
import { getEnv } from "../env";
6+
import type { Env } from "../types";
7+
8+
// https://orm.drizzle.team/docs/connect-supabase
9+
export const drizzlePersisterMiddleware = () => {
10+
return createMiddleware<Env>(async (c, next) => {
11+
const { DATABASE_URL } = getEnv(c);
12+
const client = postgres(DATABASE_URL, { prepare: false });
13+
const db = drizzle({ client });
14+
15+
c.set("db", db);
16+
await next();
17+
});
18+
};

apps/api/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { SupabaseClient, User } from "@supabase/supabase-js";
2+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
23

34
export type Variables = {
45
supabase: SupabaseClient;
56
user: User;
7+
db: PostgresJsDatabase;
68
};
79

810
export type Env = {

apps/desktop2/src/auth.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,28 @@ const AuthContext = createContext<
4141
{
4242
supabase: SupabaseClient | null;
4343
session: Session | null;
44+
apiClient: ReturnType<typeof buildApiClient> | null;
4445
} | null
4546
>(null);
4647

48+
const buildApiClient = (session: Session) => {
49+
const base = "http://localhost:5173";
50+
const apiClient = {
51+
syncWrite: (changes: any) => {
52+
return fetch(`${base}/v1/write`, {
53+
method: "POST",
54+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${session.access_token}` },
55+
body: JSON.stringify(changes),
56+
});
57+
},
58+
};
59+
60+
return apiClient;
61+
};
62+
4763
export function AuthProvider({ children }: { children: React.ReactNode }) {
4864
const [session, setSession] = useState<Session | null>(null);
65+
const [apiClient, setApiClient] = useState<ReturnType<typeof buildApiClient> | null>(null);
4966

5067
useEffect(() => {
5168
if (!supabase) {
@@ -83,9 +100,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
83100
};
84101
}, []);
85102

103+
useEffect(() => {
104+
if (!session) {
105+
setApiClient(null);
106+
} else {
107+
setApiClient(buildApiClient(session));
108+
}
109+
}, [session]);
110+
86111
const value = {
87112
session,
88113
supabase,
114+
apiClient,
89115
};
90116

91117
return (

apps/desktop2/src/components/main/main-area.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { commands as windowsCommands } from "@hypr/plugin-windows";
21
import { useNavigate, useSearch } from "@tanstack/react-router";
32
import { clsx } from "clsx";
43
import {
@@ -11,6 +10,7 @@ import {
1110
UserIcon,
1211
} from "lucide-react";
1312

13+
import { commands as windowsCommands } from "@hypr/plugin-windows";
1414
import NoteEditor from "@hypr/tiptap/editor";
1515
import { ChatPanelButton } from "@hypr/ui/components/block/chat-panel-button";
1616
import TitleInput from "@hypr/ui/components/block/title-input";

apps/desktop2/src/routes/app.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { OngoingSessionProvider2 } from "@hypr/utils/contexts";
22
import { createFileRoute, Outlet } from "@tanstack/react-router";
33

4+
import { useCloudPersister } from "../tinybase/cloudPersister";
5+
46
export const Route = createFileRoute("/app")({
57
component: Component,
68
loader: async ({ context: { ongoingSessionStore } }) => {
@@ -9,10 +11,12 @@ export const Route = createFileRoute("/app")({
911
});
1012

1113
function Component() {
14+
const sync = useCloudPersister();
1215
const { ongoingSessionStore } = Route.useLoaderData();
1316

1417
return (
1518
<OngoingSessionProvider2 store={ongoingSessionStore}>
19+
<button className="absolute top-2 right-12" onClick={() => sync()}>Sync</button>
1620
<Outlet />
1721
</OngoingSessionProvider2>
1822
);

apps/desktop2/src/routes/app/auth.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ export const Route = createFileRoute("/app/auth")({
1111
component: Component,
1212
});
1313

14-
const redirectTo = import.meta.env.DEV ? "http://localhost:5173" : "https://api.hyprnote.com";
14+
const redirectTo = import.meta.env.DEV
15+
? "http://localhost:5173/callback/auth"
16+
: "https://api.hyprnote.com/callback/auth";
1517

1618
function Component() {
1719
const auth = useAuth();

0 commit comments

Comments
 (0)