Skip to content

Commit 01d5b32

Browse files
committed
feat: add code note type and migrate to Upstash Redis + Piston
1 parent 0333dda commit 01d5b32

File tree

10 files changed

+309
-189
lines changed

10 files changed

+309
-189
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"@sentry/node": "^10.20.0",
4646
"@sentry/profiling-node": "^10.20.0",
4747
"@types/ws": "^8.18.1",
48+
"@upstash/redis": "^1.35.6",
4849
"dotenv": "^17.0.1",
4950
"dotenv-flow": "^4.1.0",
5051
"drizzle-orm": "^0.44.2",

pnpm-lock.yaml

Lines changed: 18 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__tests__/integration.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,5 +232,45 @@ describe("Test Infrastructure Integration", () => {
232232

233233
expect(updatedNote.type).toBe("diagram");
234234
});
235+
236+
it("should create a code note when type is specified", async () => {
237+
const user = await createTestUser();
238+
const note = await createTestNote(user.id, null, {
239+
type: "code",
240+
title: "Code Snippet",
241+
content: '{"language":"javascript","code":"console.log(\'Hello\');"}',
242+
});
243+
244+
expect(note.type).toBe("code");
245+
expect(note.title).toBe("Code Snippet");
246+
expect(note.content).toContain("javascript");
247+
});
248+
249+
it("should query notes by code type", async () => {
250+
const user = await createTestUser();
251+
const folder = await createTestFolder(user.id);
252+
253+
// Create mixed note types including code
254+
await createTestNote(user.id, folder.id, { type: "note", title: "Regular Note" });
255+
await createTestNote(user.id, folder.id, { type: "diagram", title: "Diagram" });
256+
await createTestNote(user.id, folder.id, {
257+
type: "code",
258+
title: "Code 1",
259+
content: '{"language":"python"}',
260+
});
261+
await createTestNote(user.id, folder.id, {
262+
type: "code",
263+
title: "Code 2",
264+
content: '{"language":"typescript"}',
265+
});
266+
267+
// Query only code notes
268+
const codeNotes = await db.query.notes.findMany({
269+
where: eq(notes.type, "code"),
270+
});
271+
272+
expect(codeNotes).toHaveLength(2);
273+
expect(codeNotes.every((n) => n.type === "code")).toBe(true);
274+
});
235275
});
236276
});

src/db/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const notes = pgTable(
4444

4545
title: text("title").notNull(),
4646
content: text("content").default(""),
47-
type: text("type", { enum: ["note", "diagram"] })
47+
type: text("type", { enum: ["note", "diagram", "code"] })
4848
.default("note")
4949
.notNull(),
5050

src/lib/cache.ts

Lines changed: 33 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,28 @@
1-
import { Cluster, ClusterOptions } from "ioredis";
1+
import { Redis } from "@upstash/redis";
22
import { logger } from "./logger";
33
import { db, folders } from "../db";
44
import { eq } from "drizzle-orm";
55
import * as Sentry from "@sentry/node";
66

7-
let client: Cluster | null = null;
7+
let client: Redis | null = null;
88

9-
export function getCacheClient(): Cluster | null {
10-
if (!process.env.VALKEY_HOST) {
11-
logger.warn("VALKEY_HOST not configured, caching disabled");
9+
export function getCacheClient(): Redis | null {
10+
if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) {
11+
logger.warn("Upstash Redis not configured, caching disabled");
1212
return null;
1313
}
1414

1515
if (!client) {
1616
try {
17-
const clusterOptions: ClusterOptions = {
18-
dnsLookup: (address, callback) => callback(null, address),
19-
redisOptions: {
20-
tls: process.env.NODE_ENV === "production" ? {} : undefined,
21-
connectTimeout: 5000,
22-
},
23-
clusterRetryStrategy: (times) => {
24-
if (times > 3) {
25-
logger.error("Valkey connection failed after 3 retries");
26-
return null;
27-
}
28-
return Math.min(times * 200, 2000);
29-
},
30-
};
31-
32-
client = new Cluster(
33-
[
34-
{
35-
host: process.env.VALKEY_HOST,
36-
port: parseInt(process.env.VALKEY_PORT || "6379"),
37-
},
38-
],
39-
clusterOptions
40-
);
41-
42-
client.on("error", (err) => {
43-
logger.error("Valkey client error", { error: err.message }, err);
17+
client = new Redis({
18+
url: process.env.UPSTASH_REDIS_REST_URL,
19+
token: process.env.UPSTASH_REDIS_REST_TOKEN,
4420
});
4521

46-
client.on("connect", () => {
47-
logger.info("Connected to Valkey cluster");
48-
});
22+
logger.info("Connected to Upstash Redis");
4923
} catch (error) {
5024
logger.error(
51-
"Failed to initialize Valkey client",
25+
"Failed to initialize Upstash Redis client",
5226
{
5327
error: error instanceof Error ? error.message : String(error),
5428
},
@@ -63,9 +37,9 @@ export function getCacheClient(): Cluster | null {
6337

6438
export async function closeCache(): Promise<void> {
6539
if (client) {
66-
await client.disconnect();
40+
// Upstash REST client doesn't need explicit disconnection
6741
client = null;
68-
logger.info("Valkey connection closed");
42+
logger.info("Upstash Redis connection closed");
6943
}
7044
}
7145

@@ -85,20 +59,20 @@ export async function getCache<T>(key: string): Promise<T | null> {
8559
async (span) => {
8660
const startTime = Date.now();
8761
try {
88-
const data = await cache.get(key);
62+
const data = await cache.get<string>(key);
8963
const duration = Date.now() - startTime;
9064
const hit = data !== null;
9165

9266
// Set Sentry span attributes
9367
span.setAttribute("cache.hit", hit);
9468
if (data) {
95-
span.setAttribute("cache.item_size", data.length);
69+
span.setAttribute("cache.item_size", JSON.stringify(data).length);
9670
}
9771

9872
// Log cache operation with metrics
9973
logger.cacheOperation("get", key, hit, duration);
10074

101-
return data ? JSON.parse(data) : null;
75+
return data ? (JSON.parse(data) as T) : null;
10276
} catch (error) {
10377
span.setStatus({ code: 2, message: "error" }); // SPAN_STATUS_ERROR
10478
logger.cacheError("get", key, error instanceof Error ? error : new Error(String(error)));
@@ -164,16 +138,12 @@ export async function deleteCache(...keys: string[]): Promise<void> {
164138
async (span) => {
165139
const startTime = Date.now();
166140
try {
167-
// In cluster mode, keys may hash to different slots
168-
// Use pipeline to delete individually (more efficient than separate awaits)
141+
// Delete keys individually (Upstash REST API)
169142
if (keys.length === 1) {
170143
await cache.del(keys[0]);
171144
} else {
172-
const pipeline = cache.pipeline();
173-
for (const key of keys) {
174-
pipeline.del(key);
175-
}
176-
await pipeline.exec();
145+
// Use Promise.all for parallel deletion
146+
await Promise.all(keys.map((key) => cache.del(key)));
177147
}
178148
const duration = Date.now() - startTime;
179149

@@ -197,36 +167,31 @@ export async function deleteCachePattern(pattern: string): Promise<void> {
197167

198168
try {
199169
const keys: string[] = [];
170+
let cursor = "0";
171+
172+
// Use SCAN to find keys matching pattern
173+
do {
174+
// Upstash REST API supports SCAN
175+
const result = await cache.scan(Number(cursor), {
176+
match: pattern,
177+
count: 100,
178+
});
200179

201-
// In cluster mode, we need to scan all master nodes
202-
const nodes = cache.nodes("master");
203-
204-
for (const node of nodes) {
205-
let cursor = "0";
206-
do {
207-
// Scan each master node individually
208-
const result = await node.scan(cursor, "MATCH", pattern, "COUNT", 100);
209-
cursor = result[0];
210-
keys.push(...result[1]);
211-
} while (cursor !== "0");
212-
}
180+
cursor = String(result[0]);
181+
keys.push(...result[1]);
182+
} while (cursor !== "0");
213183

214184
if (keys.length > 0) {
215-
// Delete in batches using pipeline (cluster mode compatible)
185+
// Delete in batches of 100
216186
const batchSize = 100;
217187
for (let i = 0; i < keys.length; i += batchSize) {
218188
const batch = keys.slice(i, i + batchSize);
219-
const pipeline = cache.pipeline();
220-
for (const key of batch) {
221-
pipeline.del(key);
222-
}
223-
await pipeline.exec();
189+
await Promise.all(batch.map((key) => cache.del(key)));
224190
}
225191

226192
logger.info(`Deleted cache keys matching pattern`, {
227193
pattern,
228194
keyCount: keys.length,
229-
nodeCount: nodes.length,
230195
});
231196
}
232197
} catch (error) {
@@ -295,7 +260,7 @@ export async function invalidateNoteCounts(userId: string, folderId: string | nu
295260
}
296261
}
297262

298-
// Delete all cache keys using pipeline for cluster compatibility
263+
// Delete all cache keys
299264
if (cacheKeys.length > 0) {
300265
await deleteCache(...cacheKeys);
301266
logger.debug("Invalidated note counts cache", {

src/lib/openapi-schemas.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ export const noteSchema = z
306306
title: z.string().openapi({ example: "[ENCRYPTED]", description: "Encrypted note title" }),
307307
content: z.string().openapi({ example: "[ENCRYPTED]", description: "Encrypted note content" }),
308308
type: z
309-
.enum(["note", "diagram"])
310-
.openapi({ example: "note", description: "Note type: 'note' or 'diagram'" }),
309+
.enum(["note", "diagram", "code"])
310+
.openapi({ example: "note", description: "Note type: 'note', 'diagram', or 'code'" }),
311311
encryptedTitle: z
312312
.string()
313313
.nullable()
@@ -375,9 +375,9 @@ export const createNoteRequestSchema = z
375375
.max(20)
376376
.optional()
377377
.openapi({ example: ["work"], description: "Up to 20 tags, max 50 chars each" }),
378-
type: z.enum(["note", "diagram"]).default("note").optional().openapi({
378+
type: z.enum(["note", "diagram", "code"]).default("note").optional().openapi({
379379
example: "note",
380-
description: "Note type: 'note' or 'diagram' (defaults to 'note' if not specified)",
380+
description: "Note type: 'note', 'diagram', or 'code' (defaults to 'note' if not specified)",
381381
}),
382382
encryptedTitle: z
383383
.string()
@@ -419,9 +419,9 @@ export const updateNoteRequestSchema = z
419419
.optional()
420420
.openapi({ example: ["work"], description: "Up to 20 tags" }),
421421
type: z
422-
.enum(["note", "diagram"])
422+
.enum(["note", "diagram", "code"])
423423
.optional()
424-
.openapi({ example: "note", description: "Note type: 'note' or 'diagram'" }),
424+
.openapi({ example: "note", description: "Note type: 'note', 'diagram', or 'code'" }),
425425
encryptedTitle: z
426426
.string()
427427
.optional()
@@ -482,7 +482,7 @@ export const notesQueryParamsSchema = z
482482
description: "Filter by hidden status",
483483
}),
484484
type: z
485-
.enum(["note", "diagram"])
485+
.enum(["note", "diagram", "code"])
486486
.optional()
487487
.openapi({
488488
param: { name: "type", in: "query" },

src/lib/validation.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const createNoteSchema = z.object({
4545
),
4646
starred: z.boolean().optional(),
4747
tags: z.array(z.string().max(50)).max(20).optional(),
48-
type: z.enum(["note", "diagram"]).default("note").optional(),
48+
type: z.enum(["note", "diagram", "code"]).default("note").optional(),
4949

5050
encryptedTitle: z.string().optional(),
5151
encryptedContent: z.string().optional(),
@@ -71,7 +71,7 @@ export const updateNoteSchema = z.object({
7171
deleted: z.boolean().optional(),
7272
hidden: z.boolean().optional(),
7373
tags: z.array(z.string().max(50)).max(20).optional(),
74-
type: z.enum(["note", "diagram"]).optional(),
74+
type: z.enum(["note", "diagram", "code"]).optional(),
7575

7676
encryptedTitle: z.string().optional(),
7777
encryptedContent: z.string().optional(),
@@ -91,7 +91,7 @@ export const notesQuerySchema = z
9191
archived: z.coerce.boolean().optional(),
9292
deleted: z.coerce.boolean().optional(),
9393
hidden: z.coerce.boolean().optional(),
94-
type: z.enum(["note", "diagram"]).optional(),
94+
type: z.enum(["note", "diagram", "code"]).optional(),
9595
search: z
9696
.string()
9797
.max(100)

0 commit comments

Comments
 (0)