Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
101 changes: 101 additions & 0 deletions src/lib/cache.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -184,3 +186,102 @@ export async function deleteCachePattern(pattern: string): Promise<void> {
);
}
}

/**
* 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<string[]> {
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<void> {
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<void> {
// Invalidate old folder hierarchy
await invalidateNoteCounts(userId, oldFolderId);

// Invalidate new folder hierarchy (if different from old)
if (oldFolderId !== newFolderId) {
await invalidateNoteCounts(userId, newFolderId);
}
}
30 changes: 26 additions & 4 deletions src/routes/folders/crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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(
Expand Down
13 changes: 13 additions & 0 deletions src/routes/notes/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
});

Expand Down
19 changes: 19 additions & 0 deletions src/routes/notes/crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
notesQueryParamsSchema,
noteIdParamSchema,
} from "../../lib/openapi-schemas";
import { invalidateNoteCounts, invalidateNoteCountsForMove } from "../../lib/cache";

const crudRouter = new OpenAPIHono();

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

// Invalidate counts cache for the user and all ancestor folders
await invalidateNoteCounts(userId, validatedData.folderId ?? null);

return c.json(newNote, 201);
};

Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// This file is automatically updated by semantic-release
export const VERSION = "1.8.1"
export const VERSION = "1.8.1";