diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ad3aa9..31a194e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,14 @@ ## [1.8.1](https://github.com/typelets/typelets-api/compare/v1.8.0...v1.8.1) (2025-10-16) - ### Bug Fixes -* resolve Redis Cluster CROSSSLOT error in cache deletions ([862d796](https://github.com/typelets/typelets-api/commit/862d796a684a45f7ca76affe8480633d8dc6220a)) +- resolve Redis Cluster CROSSSLOT error in cache deletions ([862d796](https://github.com/typelets/typelets-api/commit/862d796a684a45f7ca76affe8480633d8dc6220a)) # [1.8.0](https://github.com/typelets/typelets-api/compare/v1.7.2...v1.8.0) (2025-10-16) - ### Features -* add comprehensive OpenAPI documentation and refactor routes ([5ca9cc6](https://github.com/typelets/typelets-api/commit/5ca9cc6397b8054c41a2be5575d7233757fc9a53)) +- add comprehensive OpenAPI documentation and refactor routes ([5ca9cc6](https://github.com/typelets/typelets-api/commit/5ca9cc6397b8054c41a2be5575d7233757fc9a53)) ## [1.7.2](https://github.com/typelets/typelets-api/compare/v1.7.1...v1.7.2) (2025-10-15) diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 2cd83fe..2475e0b 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -1,5 +1,7 @@ import { Cluster, ClusterOptions } from "ioredis"; import { logger } from "./logger"; +import { db, folders } from "../db"; +import { eq } from "drizzle-orm"; let client: Cluster | null = null; @@ -184,3 +186,102 @@ export async function deleteCachePattern(pattern: string): Promise { ); } } + +/** + * Recursively get all ancestor folder IDs for a given folder + * @param folderId - The folder ID to start from + * @returns Array of ancestor folder IDs (from immediate parent to root) + */ +async function getAncestorFolderIds(folderId: string): Promise { + const ancestorIds: string[] = []; + let currentFolderId: string | null = folderId; + + // Traverse up the hierarchy until we reach a root folder (parentId is null) + while (currentFolderId) { + const folder: { parentId: string | null } | undefined = await db.query.folders.findFirst({ + where: eq(folders.id, currentFolderId), + columns: { + parentId: true, + }, + }); + + if (!folder || !folder.parentId) { + break; + } + + ancestorIds.push(folder.parentId); + currentFolderId = folder.parentId; + } + + return ancestorIds; +} + +/** + * Invalidate note counts cache for a user and all ancestor folders + * This should be called whenever notes are created, updated, deleted, or their properties change + * @param userId - The user ID + * @param folderId - The folder ID where the note resides (null for root level notes) + */ +export async function invalidateNoteCounts(userId: string, folderId: string | null): Promise { + const cache = getCacheClient(); + if (!cache) return; + + try { + const cacheKeys: string[] = []; + + // Always invalidate user's global counts (matches CacheKeys.notesCounts pattern) + cacheKeys.push(`notes:${userId}:counts`); + + // If note is in a folder, invalidate that folder and all ancestors + if (folderId) { + // Invalidate the immediate folder (matches counts.ts line 89 pattern) + cacheKeys.push(`notes:${userId}:folder:${folderId}:counts`); + + // Get and invalidate all ancestor folders + const ancestorIds = await getAncestorFolderIds(folderId); + for (const ancestorId of ancestorIds) { + cacheKeys.push(`notes:${userId}:folder:${ancestorId}:counts`); + } + } + + // Delete all cache keys using pipeline for cluster compatibility + if (cacheKeys.length > 0) { + await deleteCache(...cacheKeys); + logger.debug("Invalidated note counts cache", { + userId, + folderId: folderId || "root", + keysInvalidated: cacheKeys.length, + }); + } + } catch (error) { + logger.error( + "Failed to invalidate note counts cache", + { + userId, + folderId: folderId || "root", + }, + error instanceof Error ? error : new Error(String(error)) + ); + } +} + +/** + * Invalidate note counts cache when a note moves between folders + * Invalidates both old and new folder hierarchies + * @param userId - The user ID + * @param oldFolderId - The previous folder ID (null for root) + * @param newFolderId - The new folder ID (null for root) + */ +export async function invalidateNoteCountsForMove( + userId: string, + oldFolderId: string | null, + newFolderId: string | null +): Promise { + // Invalidate old folder hierarchy + await invalidateNoteCounts(userId, oldFolderId); + + // Invalidate new folder hierarchy (if different from old) + if (oldFolderId !== newFolderId) { + await invalidateNoteCounts(userId, newFolderId); + } +} diff --git a/src/routes/folders/crud.ts b/src/routes/folders/crud.ts index 60cf7c5..7b52928 100644 --- a/src/routes/folders/crud.ts +++ b/src/routes/folders/crud.ts @@ -4,7 +4,7 @@ import { HTTPException } from "hono/http-exception"; import { db, folders, notes } from "../../db"; import { createFolderSchema, updateFolderSchema, foldersQuerySchema } from "../../lib/validation"; import { eq, and, desc, count, asc, isNull } from "drizzle-orm"; -import { getCache, setCache, deleteCache } from "../../lib/cache"; +import { getCache, setCache, deleteCache, invalidateNoteCounts } from "../../lib/cache"; import { CacheKeys, CacheTTL } from "../../lib/cache-keys"; import { logger } from "../../lib/logger"; @@ -140,9 +140,13 @@ crudRouter.post("/", zValidator("json", createFolderSchema), async (c) => { }) .returning(); - // Invalidate cache + // Invalidate folder list cache await deleteCache(CacheKeys.foldersList(userId), CacheKeys.folderTree(userId)); + // Invalidate note counts cache for parent folder (or global if root-level) + // This ensures the counts endpoint reflects the new folder structure + await invalidateNoteCounts(userId, data.parentId || null); + return c.json(newFolder, 201); }); @@ -188,9 +192,24 @@ crudRouter.put("/:id", zValidator("json", updateFolderSchema), async (c) => { .where(eq(folders.id, folderId)) .returning(); - // Invalidate cache + // Invalidate folder list cache await deleteCache(CacheKeys.foldersList(userId), CacheKeys.folderTree(userId)); + // Invalidate note counts cache if folder moved between parents + const oldParentId = existingFolder.parentId; + const newParentId = "parentId" in data ? data.parentId || null : oldParentId; + + if (oldParentId !== newParentId) { + // Folder moved - invalidate both old and new parent counts + await invalidateNoteCounts(userId, oldParentId); + if (newParentId !== oldParentId) { + await invalidateNoteCounts(userId, newParentId); + } + } else { + // Folder properties changed but didn't move - invalidate current parent + await invalidateNoteCounts(userId, oldParentId); + } + return c.json(updatedFolder); }); @@ -256,9 +275,12 @@ crudRouter.delete("/:id", async (c) => { } }); - // Invalidate cache + // Invalidate folder list cache await deleteCache(CacheKeys.foldersList(userId), CacheKeys.folderTree(userId)); + // Invalidate note counts cache for parent folder (or global if root-level) + await invalidateNoteCounts(userId, existingFolder.parentId); + return c.json({ message: "Folder deleted successfully" }); } catch (error) { logger.error( diff --git a/src/routes/notes/actions.ts b/src/routes/notes/actions.ts index 99ee5c8..c1805fb 100644 --- a/src/routes/notes/actions.ts +++ b/src/routes/notes/actions.ts @@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception"; import { db, notes } from "../../db"; import { eq, and } from "drizzle-orm"; import { noteSchema, noteIdParamSchema } from "../../lib/openapi-schemas"; +import { invalidateNoteCounts } from "../../lib/cache"; const actionsRouter = new OpenAPIHono(); @@ -56,6 +57,9 @@ actionsRouter.openapi(starNoteRoute, async (c) => { .where(eq(notes.id, noteId)) .returning(); + // Invalidate counts cache for the note's folder hierarchy + await invalidateNoteCounts(userId, existingNote.folderId); + return c.json(updatedNote, 200); }); @@ -110,6 +114,9 @@ actionsRouter.openapi(restoreNoteRoute, async (c) => { .where(eq(notes.id, noteId)) .returning(); + // Invalidate counts cache for the note's folder hierarchy + await invalidateNoteCounts(userId, existingNote.folderId); + return c.json(restoredNote, 200); }); @@ -171,6 +178,9 @@ actionsRouter.openapi(hideNoteRoute, async (c) => { .where(eq(notes.id, noteId)) .returning(); + // Invalidate counts cache for the note's folder hierarchy + await invalidateNoteCounts(userId, existingNote.folderId); + return c.json(hiddenNote, 200); }); @@ -232,6 +242,9 @@ actionsRouter.openapi(unhideNoteRoute, async (c) => { .where(eq(notes.id, noteId)) .returning(); + // Invalidate counts cache for the note's folder hierarchy + await invalidateNoteCounts(userId, existingNote.folderId); + return c.json(unhiddenNote, 200); }); diff --git a/src/routes/notes/crud.ts b/src/routes/notes/crud.ts index 7e63c86..a66331c 100644 --- a/src/routes/notes/crud.ts +++ b/src/routes/notes/crud.ts @@ -12,6 +12,7 @@ import { notesQueryParamsSchema, noteIdParamSchema, } from "../../lib/openapi-schemas"; +import { invalidateNoteCounts, invalidateNoteCountsForMove } from "../../lib/cache"; const crudRouter = new OpenAPIHono(); @@ -267,6 +268,9 @@ const createNoteHandler: RouteHandler = async (c) => { }) .returning(); + // Invalidate counts cache for the user and all ancestor folders + await invalidateNoteCounts(userId, validatedData.folderId ?? null); + return c.json(newNote, 201); }; @@ -382,6 +386,18 @@ crudRouter.openapi(updateNoteRoute, async (c) => { .where(eq(notes.id, noteId)) .returning(); + // Invalidate counts cache - check if note moved between folders + const oldFolderId = existingNote.folderId; + const newFolderId = "folderId" in validatedData ? (validatedData.folderId ?? null) : oldFolderId; + + if (oldFolderId !== newFolderId) { + // Note moved between folders - invalidate both hierarchies + await invalidateNoteCountsForMove(userId, oldFolderId, newFolderId); + } else { + // Note stayed in same folder - just invalidate current hierarchy + await invalidateNoteCounts(userId, oldFolderId); + } + return c.json(updatedNote, 200); }); @@ -436,6 +452,9 @@ crudRouter.openapi(deleteNoteRoute, async (c) => { .where(eq(notes.id, noteId)) .returning(); + // Invalidate counts cache for the note's folder hierarchy + await invalidateNoteCounts(userId, existingNote.folderId); + return c.json(deletedNote, 200); }); diff --git a/src/version.ts b/src/version.ts index 3e9769a..ecb596d 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // This file is automatically updated by semantic-release -export const VERSION = "1.8.1" \ No newline at end of file +export const VERSION = "1.8.1";