Skip to content

Commit 6500247

Browse files
committed
feat: optimize notes counts endpoint and add sheets note type
- Add batched folder descendant queries (N queries → 1 query) - Add composite database indexes for common query patterns - Add 'sheets' as valid note type across all schemas Performance: /notes/counts ~300ms → ~80ms (73% faster)
1 parent 2205729 commit 6500247

File tree

4 files changed

+87
-40
lines changed

4 files changed

+87
-40
lines changed

src/db/schema.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const folders = pgTable(
2828
(table) => ({
2929
userIdIdx: index("idx_folders_user_id").on(table.userId),
3030
userSortIdx: index("idx_folders_user_sort").on(table.userId, table.sortOrder.asc()),
31+
parentIdIdx: index("idx_folders_parent_id").on(table.parentId),
3132
})
3233
);
3334

@@ -44,7 +45,7 @@ export const notes = pgTable(
4445

4546
title: text("title").notNull(),
4647
content: text("content").default(""),
47-
type: text("type", { enum: ["note", "diagram", "code"] })
48+
type: text("type", { enum: ["note", "diagram", "code", "sheets"] })
4849
.default("note")
4950
.notNull(),
5051

@@ -68,6 +69,13 @@ export const notes = pgTable(
6869
folderIdIdx: index("idx_notes_folder_id").on(table.folderId),
6970
userUpdatedIdx: index("idx_notes_user_updated").on(table.userId, table.updatedAt.desc()),
7071
typeIdx: index("idx_notes_type").on(table.type),
72+
// Composite indexes for common query patterns
73+
userDeletedArchivedIdx: index("idx_notes_user_deleted_archived").on(
74+
table.userId,
75+
table.deleted,
76+
table.archived
77+
),
78+
userStarredIdx: index("idx_notes_user_starred").on(table.userId, table.starred, table.deleted),
7179
})
7280
);
7381

@@ -166,7 +174,7 @@ export const publicNotes = pgTable(
166174
.notNull(),
167175
title: text("title").notNull(), // Plaintext title (NOT encrypted)
168176
content: text("content").notNull(), // Plaintext HTML content (NOT encrypted)
169-
type: text("type", { enum: ["note", "diagram", "code"] })
177+
type: text("type", { enum: ["note", "diagram", "code", "sheets"] })
170178
.default("note")
171179
.notNull(),
172180
authorName: text("author_name"), // Optional display name

src/lib/openapi-schemas.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -319,9 +319,10 @@ export const noteSchema = z
319319
.openapi({ example: "123e4567-e89b-12d3-a456-426614174000", description: "Folder ID" }),
320320
title: z.string().openapi({ example: "[ENCRYPTED]", description: "Encrypted note title" }),
321321
content: z.string().openapi({ example: "[ENCRYPTED]", description: "Encrypted note content" }),
322-
type: z
323-
.enum(["note", "diagram", "code"])
324-
.openapi({ example: "note", description: "Note type: 'note', 'diagram', or 'code'" }),
322+
type: z.enum(["note", "diagram", "code", "sheets"]).openapi({
323+
example: "note",
324+
description: "Note type: 'note', 'diagram', 'code', or 'sheets'",
325+
}),
325326
encryptedTitle: z
326327
.string()
327328
.nullable()
@@ -405,9 +406,10 @@ export const createNoteRequestSchema = z
405406
.max(20)
406407
.optional()
407408
.openapi({ example: ["work"], description: "Up to 20 tags, max 50 chars each" }),
408-
type: z.enum(["note", "diagram", "code"]).default("note").optional().openapi({
409+
type: z.enum(["note", "diagram", "code", "sheets"]).default("note").optional().openapi({
409410
example: "note",
410-
description: "Note type: 'note', 'diagram', or 'code' (defaults to 'note' if not specified)",
411+
description:
412+
"Note type: 'note', 'diagram', 'code', or 'sheets' (defaults to 'note' if not specified)",
411413
}),
412414
encryptedTitle: z
413415
.string()
@@ -448,10 +450,10 @@ export const updateNoteRequestSchema = z
448450
.max(20)
449451
.optional()
450452
.openapi({ example: ["work"], description: "Up to 20 tags" }),
451-
type: z
452-
.enum(["note", "diagram", "code"])
453-
.optional()
454-
.openapi({ example: "note", description: "Note type: 'note', 'diagram', or 'code'" }),
453+
type: z.enum(["note", "diagram", "code", "sheets"]).optional().openapi({
454+
example: "note",
455+
description: "Note type: 'note', 'diagram', 'code', or 'sheets'",
456+
}),
455457
encryptedTitle: z
456458
.string()
457459
.optional()
@@ -512,7 +514,7 @@ export const notesQueryParamsSchema = z
512514
description: "Filter by hidden status",
513515
}),
514516
type: z
515-
.enum(["note", "diagram", "code"])
517+
.enum(["note", "diagram", "code", "sheets"])
516518
.optional()
517519
.openapi({
518520
param: { name: "type", in: "query" },
@@ -807,7 +809,7 @@ export const publicNoteSchema = z
807809
.string()
808810
.openapi({ example: "<p>Note content...</p>", description: "Plaintext HTML content" }),
809811
type: z
810-
.enum(["note", "diagram", "code"])
812+
.enum(["note", "diagram", "code", "sheets"])
811813
.openapi({ example: "note", description: "Note type" }),
812814
authorName: z
813815
.string()
@@ -833,7 +835,7 @@ export const publicNoteViewSchema = z
833835
.string()
834836
.openapi({ example: "<p>Note content...</p>", description: "Plaintext HTML content" }),
835837
type: z
836-
.enum(["note", "diagram", "code"])
838+
.enum(["note", "diagram", "code", "sheets"])
837839
.openapi({ example: "note", description: "Note type" }),
838840
authorName: z
839841
.string()
@@ -866,7 +868,7 @@ export const publishNoteRequestSchema = z
866868
.max(5 * 1024 * 1024) // 5MB max
867869
.openapi({ example: "<p>Note content...</p>", description: "Plaintext HTML content" }),
868870
type: z
869-
.enum(["note", "diagram", "code"])
871+
.enum(["note", "diagram", "code", "sheets"])
870872
.optional()
871873
.openapi({ example: "note", description: "Note type (defaults to 'note')" }),
872874
authorName: z

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", "code"]).default("note").optional(),
48+
type: z.enum(["note", "diagram", "code", "sheets"]).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", "code"]).optional(),
74+
type: z.enum(["note", "diagram", "code", "sheets"]).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", "code"]).optional(),
94+
type: z.enum(["note", "diagram", "code", "sheets"]).optional(),
9595
search: z
9696
.string()
9797
.max(100)

src/routes/notes/counts.ts

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,56 @@ async function getAllDescendantFolderIds(
4848
return (result as unknown as { id: string }[]).map((row) => row.id);
4949
}
5050

51+
// Batched version: Get descendants for multiple root folders, grouped by root
52+
async function getAllDescendantsByRootFolder(
53+
rootFolderIds: string[],
54+
userId: string
55+
): Promise<Map<string, string[]>> {
56+
if (rootFolderIds.length === 0) {
57+
return new Map();
58+
}
59+
60+
const queryStart = Date.now();
61+
// Single query that tracks which root folder each descendant belongs to
62+
const result = await db.execute<{ root_id: string; descendant_id: string }>(sql`
63+
WITH RECURSIVE folder_tree AS (
64+
-- Base case: start with root folders, track the root_id
65+
SELECT id as root_id, id as descendant_id, parent_id
66+
FROM folders
67+
WHERE id IN (${sql.join(
68+
rootFolderIds.map((id) => sql`${id}`),
69+
sql`, `
70+
)})
71+
AND user_id = ${userId}
72+
73+
UNION ALL
74+
75+
-- Recursive case: get children, preserve root_id
76+
SELECT ft.root_id, f.id as descendant_id, f.parent_id
77+
FROM folders f
78+
INNER JOIN folder_tree ft ON f.parent_id = ft.descendant_id
79+
WHERE f.user_id = ${userId}
80+
)
81+
SELECT DISTINCT root_id, descendant_id FROM folder_tree
82+
`);
83+
logger.databaseQuery("select_recursive", "folders", Date.now() - queryStart, userId);
84+
85+
// Group descendants by root folder
86+
const folderMap = new Map<string, string[]>();
87+
for (const rootId of rootFolderIds) {
88+
folderMap.set(rootId, []);
89+
}
90+
91+
for (const row of result as unknown as { root_id: string; descendant_id: string }[]) {
92+
const descendants = folderMap.get(row.root_id);
93+
if (descendants) {
94+
descendants.push(row.descendant_id);
95+
}
96+
}
97+
98+
return folderMap;
99+
}
100+
51101
// Optimized helper to get counts for multiple folders in a single query
52102
async function getCountsForFolders(
53103
folderIds: string[],
@@ -157,13 +207,9 @@ export async function warmNotesCountsCache(userId: string): Promise<void> {
157207
> = {};
158208

159209
if (rootFolders.length > 0) {
160-
// Build a map of folder ID to its descendants
161-
const folderToDescendants = new Map<string, string[]>();
162-
163-
for (const rootFolder of rootFolders) {
164-
const descendants = await getAllDescendantFolderIds(rootFolder.id, userId);
165-
folderToDescendants.set(rootFolder.id, descendants);
166-
}
210+
// Get descendants for ALL root folders in a single query (batched)
211+
const rootFolderIds = rootFolders.map((f) => f.id);
212+
const folderToDescendants = await getAllDescendantsByRootFolder(rootFolderIds, userId);
167213

168214
// Get counts for all folders in parallel
169215
const folderCountsPromises = Array.from(folderToDescendants.entries()).map(
@@ -273,14 +319,9 @@ const getNotesCountsHandler: RouteHandler<typeof getNotesCountsRoute> = async (c
273319
return c.json({}, 200);
274320
}
275321

276-
// Build a map of folder ID to its descendants (including itself)
277-
const folderToDescendants = new Map<string, string[]>();
278-
279-
for (const childFolder of childFolders) {
280-
// Get descendants for this specific child
281-
const descendants = await getAllDescendantFolderIds(childFolder.id, userId);
282-
folderToDescendants.set(childFolder.id, descendants);
283-
}
322+
// Get descendants for ALL child folders in a single query (batched)
323+
const childFolderIds = childFolders.map((f) => f.id);
324+
const folderToDescendants = await getAllDescendantsByRootFolder(childFolderIds, userId);
284325

285326
// Get counts for all folders in parallel
286327
const folderCountsPromises = Array.from(folderToDescendants.entries()).map(
@@ -389,13 +430,9 @@ const getNotesCountsHandler: RouteHandler<typeof getNotesCountsRoute> = async (c
389430
> = {};
390431

391432
if (rootFolders.length > 0) {
392-
// Build a map of folder ID to its descendants (including itself)
393-
const folderToDescendants = new Map<string, string[]>();
394-
395-
for (const rootFolder of rootFolders) {
396-
const descendants = await getAllDescendantFolderIds(rootFolder.id, userId);
397-
folderToDescendants.set(rootFolder.id, descendants);
398-
}
433+
// Get descendants for ALL root folders in a single query (batched)
434+
const rootFolderIds = rootFolders.map((f) => f.id);
435+
const folderToDescendants = await getAllDescendantsByRootFolder(rootFolderIds, userId);
399436

400437
// Get counts for all folders in parallel
401438
const folderCountsPromises = Array.from(folderToDescendants.entries()).map(

0 commit comments

Comments
 (0)