Skip to content

Commit 93a81a2

Browse files
authored
Merge pull request #5 from typelets/fix/valkey-cache-invalidation
fix: invalidate note counts cache on note and folder mutations
2 parents 73d5585 + 9c497bb commit 93a81a2

File tree

6 files changed

+162
-9
lines changed

6 files changed

+162
-9
lines changed

CHANGELOG.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
## [1.8.1](https://github.com/typelets/typelets-api/compare/v1.8.0...v1.8.1) (2025-10-16)
22

3-
43
### Bug Fixes
54

6-
* resolve Redis Cluster CROSSSLOT error in cache deletions ([862d796](https://github.com/typelets/typelets-api/commit/862d796a684a45f7ca76affe8480633d8dc6220a))
5+
- resolve Redis Cluster CROSSSLOT error in cache deletions ([862d796](https://github.com/typelets/typelets-api/commit/862d796a684a45f7ca76affe8480633d8dc6220a))
76

87
# [1.8.0](https://github.com/typelets/typelets-api/compare/v1.7.2...v1.8.0) (2025-10-16)
98

10-
119
### Features
1210

13-
* add comprehensive OpenAPI documentation and refactor routes ([5ca9cc6](https://github.com/typelets/typelets-api/commit/5ca9cc6397b8054c41a2be5575d7233757fc9a53))
11+
- add comprehensive OpenAPI documentation and refactor routes ([5ca9cc6](https://github.com/typelets/typelets-api/commit/5ca9cc6397b8054c41a2be5575d7233757fc9a53))
1412

1513
## [1.7.2](https://github.com/typelets/typelets-api/compare/v1.7.1...v1.7.2) (2025-10-15)
1614

src/lib/cache.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Cluster, ClusterOptions } from "ioredis";
22
import { logger } from "./logger";
3+
import { db, folders } from "../db";
4+
import { eq } from "drizzle-orm";
35

46
let client: Cluster | null = null;
57

@@ -184,3 +186,102 @@ export async function deleteCachePattern(pattern: string): Promise<void> {
184186
);
185187
}
186188
}
189+
190+
/**
191+
* Recursively get all ancestor folder IDs for a given folder
192+
* @param folderId - The folder ID to start from
193+
* @returns Array of ancestor folder IDs (from immediate parent to root)
194+
*/
195+
async function getAncestorFolderIds(folderId: string): Promise<string[]> {
196+
const ancestorIds: string[] = [];
197+
let currentFolderId: string | null = folderId;
198+
199+
// Traverse up the hierarchy until we reach a root folder (parentId is null)
200+
while (currentFolderId) {
201+
const folder: { parentId: string | null } | undefined = await db.query.folders.findFirst({
202+
where: eq(folders.id, currentFolderId),
203+
columns: {
204+
parentId: true,
205+
},
206+
});
207+
208+
if (!folder || !folder.parentId) {
209+
break;
210+
}
211+
212+
ancestorIds.push(folder.parentId);
213+
currentFolderId = folder.parentId;
214+
}
215+
216+
return ancestorIds;
217+
}
218+
219+
/**
220+
* Invalidate note counts cache for a user and all ancestor folders
221+
* This should be called whenever notes are created, updated, deleted, or their properties change
222+
* @param userId - The user ID
223+
* @param folderId - The folder ID where the note resides (null for root level notes)
224+
*/
225+
export async function invalidateNoteCounts(userId: string, folderId: string | null): Promise<void> {
226+
const cache = getCacheClient();
227+
if (!cache) return;
228+
229+
try {
230+
const cacheKeys: string[] = [];
231+
232+
// Always invalidate user's global counts (matches CacheKeys.notesCounts pattern)
233+
cacheKeys.push(`notes:${userId}:counts`);
234+
235+
// If note is in a folder, invalidate that folder and all ancestors
236+
if (folderId) {
237+
// Invalidate the immediate folder (matches counts.ts line 89 pattern)
238+
cacheKeys.push(`notes:${userId}:folder:${folderId}:counts`);
239+
240+
// Get and invalidate all ancestor folders
241+
const ancestorIds = await getAncestorFolderIds(folderId);
242+
for (const ancestorId of ancestorIds) {
243+
cacheKeys.push(`notes:${userId}:folder:${ancestorId}:counts`);
244+
}
245+
}
246+
247+
// Delete all cache keys using pipeline for cluster compatibility
248+
if (cacheKeys.length > 0) {
249+
await deleteCache(...cacheKeys);
250+
logger.debug("Invalidated note counts cache", {
251+
userId,
252+
folderId: folderId || "root",
253+
keysInvalidated: cacheKeys.length,
254+
});
255+
}
256+
} catch (error) {
257+
logger.error(
258+
"Failed to invalidate note counts cache",
259+
{
260+
userId,
261+
folderId: folderId || "root",
262+
},
263+
error instanceof Error ? error : new Error(String(error))
264+
);
265+
}
266+
}
267+
268+
/**
269+
* Invalidate note counts cache when a note moves between folders
270+
* Invalidates both old and new folder hierarchies
271+
* @param userId - The user ID
272+
* @param oldFolderId - The previous folder ID (null for root)
273+
* @param newFolderId - The new folder ID (null for root)
274+
*/
275+
export async function invalidateNoteCountsForMove(
276+
userId: string,
277+
oldFolderId: string | null,
278+
newFolderId: string | null
279+
): Promise<void> {
280+
// Invalidate old folder hierarchy
281+
await invalidateNoteCounts(userId, oldFolderId);
282+
283+
// Invalidate new folder hierarchy (if different from old)
284+
if (oldFolderId !== newFolderId) {
285+
await invalidateNoteCounts(userId, newFolderId);
286+
}
287+
}

src/routes/folders/crud.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { HTTPException } from "hono/http-exception";
44
import { db, folders, notes } from "../../db";
55
import { createFolderSchema, updateFolderSchema, foldersQuerySchema } from "../../lib/validation";
66
import { eq, and, desc, count, asc, isNull } from "drizzle-orm";
7-
import { getCache, setCache, deleteCache } from "../../lib/cache";
7+
import { getCache, setCache, deleteCache, invalidateNoteCounts } from "../../lib/cache";
88
import { CacheKeys, CacheTTL } from "../../lib/cache-keys";
99
import { logger } from "../../lib/logger";
1010

@@ -140,9 +140,13 @@ crudRouter.post("/", zValidator("json", createFolderSchema), async (c) => {
140140
})
141141
.returning();
142142

143-
// Invalidate cache
143+
// Invalidate folder list cache
144144
await deleteCache(CacheKeys.foldersList(userId), CacheKeys.folderTree(userId));
145145

146+
// Invalidate note counts cache for parent folder (or global if root-level)
147+
// This ensures the counts endpoint reflects the new folder structure
148+
await invalidateNoteCounts(userId, data.parentId || null);
149+
146150
return c.json(newFolder, 201);
147151
});
148152

@@ -188,9 +192,24 @@ crudRouter.put("/:id", zValidator("json", updateFolderSchema), async (c) => {
188192
.where(eq(folders.id, folderId))
189193
.returning();
190194

191-
// Invalidate cache
195+
// Invalidate folder list cache
192196
await deleteCache(CacheKeys.foldersList(userId), CacheKeys.folderTree(userId));
193197

198+
// Invalidate note counts cache if folder moved between parents
199+
const oldParentId = existingFolder.parentId;
200+
const newParentId = "parentId" in data ? data.parentId || null : oldParentId;
201+
202+
if (oldParentId !== newParentId) {
203+
// Folder moved - invalidate both old and new parent counts
204+
await invalidateNoteCounts(userId, oldParentId);
205+
if (newParentId !== oldParentId) {
206+
await invalidateNoteCounts(userId, newParentId);
207+
}
208+
} else {
209+
// Folder properties changed but didn't move - invalidate current parent
210+
await invalidateNoteCounts(userId, oldParentId);
211+
}
212+
194213
return c.json(updatedFolder);
195214
});
196215

@@ -256,9 +275,12 @@ crudRouter.delete("/:id", async (c) => {
256275
}
257276
});
258277

259-
// Invalidate cache
278+
// Invalidate folder list cache
260279
await deleteCache(CacheKeys.foldersList(userId), CacheKeys.folderTree(userId));
261280

281+
// Invalidate note counts cache for parent folder (or global if root-level)
282+
await invalidateNoteCounts(userId, existingFolder.parentId);
283+
262284
return c.json({ message: "Folder deleted successfully" });
263285
} catch (error) {
264286
logger.error(

src/routes/notes/actions.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception";
33
import { db, notes } from "../../db";
44
import { eq, and } from "drizzle-orm";
55
import { noteSchema, noteIdParamSchema } from "../../lib/openapi-schemas";
6+
import { invalidateNoteCounts } from "../../lib/cache";
67

78
const actionsRouter = new OpenAPIHono();
89

@@ -56,6 +57,9 @@ actionsRouter.openapi(starNoteRoute, async (c) => {
5657
.where(eq(notes.id, noteId))
5758
.returning();
5859

60+
// Invalidate counts cache for the note's folder hierarchy
61+
await invalidateNoteCounts(userId, existingNote.folderId);
62+
5963
return c.json(updatedNote, 200);
6064
});
6165

@@ -110,6 +114,9 @@ actionsRouter.openapi(restoreNoteRoute, async (c) => {
110114
.where(eq(notes.id, noteId))
111115
.returning();
112116

117+
// Invalidate counts cache for the note's folder hierarchy
118+
await invalidateNoteCounts(userId, existingNote.folderId);
119+
113120
return c.json(restoredNote, 200);
114121
});
115122

@@ -171,6 +178,9 @@ actionsRouter.openapi(hideNoteRoute, async (c) => {
171178
.where(eq(notes.id, noteId))
172179
.returning();
173180

181+
// Invalidate counts cache for the note's folder hierarchy
182+
await invalidateNoteCounts(userId, existingNote.folderId);
183+
174184
return c.json(hiddenNote, 200);
175185
});
176186

@@ -232,6 +242,9 @@ actionsRouter.openapi(unhideNoteRoute, async (c) => {
232242
.where(eq(notes.id, noteId))
233243
.returning();
234244

245+
// Invalidate counts cache for the note's folder hierarchy
246+
await invalidateNoteCounts(userId, existingNote.folderId);
247+
235248
return c.json(unhiddenNote, 200);
236249
});
237250

src/routes/notes/crud.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
notesQueryParamsSchema,
1313
noteIdParamSchema,
1414
} from "../../lib/openapi-schemas";
15+
import { invalidateNoteCounts, invalidateNoteCountsForMove } from "../../lib/cache";
1516

1617
const crudRouter = new OpenAPIHono();
1718

@@ -267,6 +268,9 @@ const createNoteHandler: RouteHandler<typeof createNoteRoute> = async (c) => {
267268
})
268269
.returning();
269270

271+
// Invalidate counts cache for the user and all ancestor folders
272+
await invalidateNoteCounts(userId, validatedData.folderId ?? null);
273+
270274
return c.json(newNote, 201);
271275
};
272276

@@ -382,6 +386,18 @@ crudRouter.openapi(updateNoteRoute, async (c) => {
382386
.where(eq(notes.id, noteId))
383387
.returning();
384388

389+
// Invalidate counts cache - check if note moved between folders
390+
const oldFolderId = existingNote.folderId;
391+
const newFolderId = "folderId" in validatedData ? (validatedData.folderId ?? null) : oldFolderId;
392+
393+
if (oldFolderId !== newFolderId) {
394+
// Note moved between folders - invalidate both hierarchies
395+
await invalidateNoteCountsForMove(userId, oldFolderId, newFolderId);
396+
} else {
397+
// Note stayed in same folder - just invalidate current hierarchy
398+
await invalidateNoteCounts(userId, oldFolderId);
399+
}
400+
385401
return c.json(updatedNote, 200);
386402
});
387403

@@ -436,6 +452,9 @@ crudRouter.openapi(deleteNoteRoute, async (c) => {
436452
.where(eq(notes.id, noteId))
437453
.returning();
438454

455+
// Invalidate counts cache for the note's folder hierarchy
456+
await invalidateNoteCounts(userId, existingNote.folderId);
457+
439458
return c.json(deletedNote, 200);
440459
});
441460

src/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
// This file is automatically updated by semantic-release
2-
export const VERSION = "1.8.1"
2+
export const VERSION = "1.8.1";

0 commit comments

Comments
 (0)