diff --git a/package.json b/package.json index 53dc0c0..dc33134 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "*.{ts,tsx,js,jsx}": ["eslint"] }, "resolutions": { - "@nimpl/cache-adapter>@nimpl/cache": "workspace:*" + "@nimpl/cache-adapter>@nimpl/cache": "workspace:*", + "@nimpl/cache-server>@nimpl/cache": "workspace:*" }, "license": "MIT", "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a" diff --git a/packages/cache-server/README.md b/packages/cache-server/README.md index c2df62f..467b272 100644 --- a/packages/cache-server/README.md +++ b/packages/cache-server/README.md @@ -178,6 +178,14 @@ Stores a cache entry. - `400 Bad Request`: Missing key or metadata - `500 Internal Server Error`: Server error +### `PUT /?key=` + +Updates cache lifetimes for a single key using the handler’s `updateKey` method. + +- **Query Parameters:** + - `key` (required): The cache key to update +- **Body:** JSON object with optional `durations` object (e.g. `{ "durations": { "expire": 60 } }`) + ### `PUT /` Updates tags for cache entries. diff --git a/packages/cache-server/src/index.ts b/packages/cache-server/src/index.ts index 213d027..5e69db9 100644 --- a/packages/cache-server/src/index.ts +++ b/packages/cache-server/src/index.ts @@ -75,7 +75,6 @@ export const init = (cacheHandler: CacheHandlerRoot, options?: Pick return getKeyDetails(cacheHandler, type, segments[1]); }; +export const updateTags = async (cacheHandler: CacheHandler, tags: string[], duration: number) => { + return cacheHandler.updateTags(tags, { expire: duration }); +}; + +export const updateKey = async (cacheHandler: CacheHandler, key: string, duration: number) => { + return cacheHandler.updateKey(key, { expire: duration }); +}; + export const createHelpers = (cacheHandler: CacheHandler) => { return { getKeys: (type: "main" | "persistent" | "ephemeral") => getKeys(cacheHandler, type), getKeyDetails: (type: "main" | "persistent" | "ephemeral", key: string) => getKeyDetails(cacheHandler, type, key), getCacheData: (segments?: string[]) => getCacheData(cacheHandler, segments), + updateTags: (tags: string[], duration: number) => updateTags(cacheHandler, tags, duration), + updateKey: (key: string, duration: number) => updateKey(cacheHandler, key, duration), }; }; diff --git a/packages/cache-tools/src/lib/types.ts b/packages/cache-tools/src/lib/types.ts index c0617db..24e7ddc 100644 --- a/packages/cache-tools/src/lib/types.ts +++ b/packages/cache-tools/src/lib/types.ts @@ -25,6 +25,8 @@ export type CacheHandler = { get: (key: string) => Promise; set: (key: string, value: Promise) => Promise; keys: () => Promise; + updateTags: (tags: string[], durations?: { expire?: number }) => Promise; + updateKey: (key: string, durations?: { expire?: number }) => Promise; ephemeralLayer: { getEntry: (key: string) => Promise; get: (key: string) => Promise; diff --git a/packages/cache/README.md b/packages/cache/README.md index 1886b66..0347403 100644 --- a/packages/cache/README.md +++ b/packages/cache/README.md @@ -102,7 +102,8 @@ The fetch layer communicates with a cache server that implements the following H - `GET /?key=...` - Retrieve cache entry (returns stream with `x-cache-metadata` header) - `POST /?key=...` - Store cache entry (expects stream body and `x-cache-metadata` header) -- `PUT /` - Update tags (expects JSON body with `tags` and `durations`) +- `PUT /` - Update tags for matching entries (expects JSON body with `tags` and optional `durations`) +- `PUT /?key=...` - Update a single cache key (`updateKey`, expects JSON body with optional `durations`) - `DELETE /?key=...` - Delete cache entry - `GET /keys` - Get all cache keys (returns JSON array) - `GET /readiness` - Health check (returns ok status) @@ -263,7 +264,7 @@ These methods are improved versions of the default Next.js `cacheHandler` for be - `get(key: string)`: Promise - Retrieves a cache entry. Returns `undefined` if not found, `null` if expired. - `set(key: string, pendingEntry: Promise | Entry)`: Promise - Stores a cache entry. -- `updateTags(tags: string[], durations?: Durations)`: Promise - Updates tags for cache entries, used for cache invalidation. +- `updateTags(tags: string[], durations?: Durations)`: Promise - Updates cache entries by tags, used for cache invalidation. - `refreshTags()`: Promise - Refreshes tags from persistent storage. - `getExpiration()`: Promise - Returns the expiration time for cache entries. @@ -278,6 +279,7 @@ These methods are critically necessary and can be reused from any other implemen - `checkIsReady()`: Promise - Checks if the layer is ready to serve requests. Useful for health checks and infrastructure tools. - `keys()`: Promise - Returns all cache keys. Useful for tools and different infrastructures. - `delete(key: string)`: Promise - Deletes a cache entry. Useful for tools and manual cache management. +- `updateKey(key: string, durations?: Durations)`: Promise - Updates cache entry by key, used for cache invalidation. #### Core Handlers diff --git a/packages/cache/src/cache-handler.ts b/packages/cache/src/cache-handler.ts index 6678d65..749f86f 100644 --- a/packages/cache/src/cache-handler.ts +++ b/packages/cache/src/cache-handler.ts @@ -33,7 +33,7 @@ export class CacheHandler implements CacheHandlerRoot { } private logOperation( - type: "GET" | "SET" | "UPDATE_TAGS", + type: "GET" | "SET" | "UPDATE_TAGS" | "UPDATE_KEY", status: LogData["status"], source: LogData["source"], key: string, @@ -156,6 +156,13 @@ export class CacheHandler implements CacheHandlerRoot { return Infinity; } + async updateKey(key: string, durations?: Durations) { + this.logOperation("UPDATE_KEY", "REVALIDATING", "EPHEMERAL", key); + await this.ephemeralLayer.updateKey(key, durations); + this.logOperation("UPDATE_KEY", "REVALIDATING", "PERSISTENT", key); + await this.persistentLayer.updateKey(key, durations); + } + async updateTags(tags: string[], durations?: Durations) { const tagsKey = tags.join(","); if (!tags.length) { diff --git a/packages/cache/src/layers/fetch-layer/index.ts b/packages/cache/src/layers/fetch-layer/index.ts index fab365e..badf797 100644 --- a/packages/cache/src/layers/fetch-layer/index.ts +++ b/packages/cache/src/layers/fetch-layer/index.ts @@ -97,6 +97,23 @@ export class FetchLayer implements CacheHandlerLayer { } } + async updateKey(key: string, durations?: Durations) { + const result = await this.fetchFn(`${this.baseUrl}/?key=${encodeURIComponent(key)}`, { + method: "PUT", + body: JSON.stringify({ durations }), + }); + + if (!result.ok) { + this.logger({ + type: "UPDATE_KEY", + status: "ERROR", + source: "FETCH", + key, + message: result.statusText || "Failed to update key", + }); + } + } + async updateTags(tags: string[], durations?: Durations) { const result = await this.fetchFn(`${this.baseUrl}/`, { method: "PUT", diff --git a/packages/cache/src/layers/fs-layer/index.ts b/packages/cache/src/layers/fs-layer/index.ts index d8ec0c1..145ab67 100644 --- a/packages/cache/src/layers/fs-layer/index.ts +++ b/packages/cache/src/layers/fs-layer/index.ts @@ -168,6 +168,33 @@ export class FsLayer implements CacheHandlerLayer { } } + async updateKey(key: string, durations?: Durations) { + await this.ensureBaseDir(); + const metaPath = this.getFilePath(getCacheKeys(key, "").metaKey); + try { + const content = await readFile(metaPath, "utf8"); + const metadata: Metadata = JSON.parse(content); + const updated = getUpdatedMetadata( + metadata, + metadata.tags, + durations, + performance.timeOrigin + performance.now(), + ); + + if (updated === metadata) return; + + await writeFile(metaPath, JSON.stringify(updated), "utf8"); + } catch (error) { + this.logger({ + type: "UPDATE_KEY", + status: "ERROR", + source: "FS", + key, + message: error instanceof Error ? error.message : "Failed to update key in filesystem cache", + }); + } + } + async updateTags(tags: string[], durations?: Durations) { await this.ensureBaseDir(); const metaPrefix = encodeURIComponent(PREFIX_META); diff --git a/packages/cache/src/layers/lru-layer/index.ts b/packages/cache/src/layers/lru-layer/index.ts index 36288bd..74b7b30 100644 --- a/packages/cache/src/layers/lru-layer/index.ts +++ b/packages/cache/src/layers/lru-layer/index.ts @@ -78,6 +78,21 @@ export class LruLayer implements CacheHandlerLayer { this.lruClient.delete(key); } + async updateKey(key: string, durations?: Durations) { + const now = performance.timeOrigin + performance.now(); + const cacheEntry = this.lruClient.get(key); + + if (!cacheEntry) return; + + const { entry, size, status } = cacheEntry; + const updatedMetadata = getUpdatedMetadata(entry, entry.tags, durations, now); + + if (updatedMetadata === entry) return; + + const updatedEntry: Entry = { ...entry, ...updatedMetadata }; + this.lruClient.set(key, { entry: updatedEntry, size, status }); + } + async updateTags(tags: string[], durations?: Durations) { const now = performance.timeOrigin + performance.now(); this.lruClient.forEach((value, key) => { diff --git a/packages/cache/src/layers/redis-layer/index.ts b/packages/cache/src/layers/redis-layer/index.ts index c249fd2..917ceb4 100644 --- a/packages/cache/src/layers/redis-layer/index.ts +++ b/packages/cache/src/layers/redis-layer/index.ts @@ -229,6 +229,24 @@ export class RedisLayer implements CacheHandlerLayer { } } + async updateKey(key: string, durations?: Durations) { + const connected = await this.connect(); + if (!connected) return; + + const { metaKey } = getCacheKeys(key, this.keyPrefix); + const metaEntry = await this.redisClient.get(metaKey); + if (!metaEntry) return; + const metadata: Metadata = JSON.parse(metaEntry); + const updated = getUpdatedMetadata( + metadata, + metadata.tags, + durations, + performance.timeOrigin + performance.now(), + ); + if (updated === metadata) return; + await this.redisClient.set(metaKey, JSON.stringify(updated), "EX", updated.expire); + } + async updateTags(tags: string[], durations?: Durations) { const connected = await this.connect(); if (!connected) return; diff --git a/packages/cache/src/types.ts b/packages/cache/src/types.ts index 8e459c6..376a397 100644 --- a/packages/cache/src/types.ts +++ b/packages/cache/src/types.ts @@ -25,7 +25,7 @@ export type CacheEntry = { }; export type LogData = { - type: "GET" | "SET" | "UPDATE_TAGS" | "CONNECTION"; + type: "GET" | "SET" | "UPDATE_TAGS" | "UPDATE_KEY" | "CONNECTION"; status: | "HIT" | "MISS" @@ -56,6 +56,7 @@ export interface CacheHandlerLayer { get(key: string): Promise; set(key: string, pendingEntry: Promise | Entry): Promise; delete(key: string): Promise; + updateKey(key: string, durations?: Durations): Promise; updateTags(tags: string[], durations?: Durations): Promise; checkIsReady(): Promise; keys(): Promise; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 482dc55..59a99d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: '@nimpl/cache-adapter>@nimpl/cache': workspace:* + '@nimpl/cache-server>@nimpl/cache': workspace:* importers: @@ -196,8 +197,8 @@ importers: packages/cache-server: devDependencies: '@nimpl/cache': - specifier: latest - version: 0.2.0 + specifier: workspace:* + version: link:../cache '@rollup/plugin-commonjs': specifier: 29.0.0 version: 29.0.0(rollup@4.53.3) @@ -1292,9 +1293,6 @@ packages: '@nimpl/cache@0.0.0-experimental-e9174c4': resolution: {integrity: sha512-AdBHHOWIYQYni/7N3JZ+wpQcmo0XMl/vGMt6UA6+RCJb6eAMr3VBsGEOtDSf4MHUK8kLbNmvyXT8o0ctTZK7uQ==} - '@nimpl/cache@0.2.0': - resolution: {integrity: sha512-wMOP1r/vAvduHRujb2lLiUZlWcc6TvkrJG7tuIObEwBiimAmYOJT8NXe6HyI1+FmYaZxB2M0LQldFWrdbPhFrQ==} - '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -4296,14 +4294,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@nimpl/cache@0.2.0': - dependencies: - chalk: 4.1.2 - ioredis: 5.8.2 - lru-cache: 11.2.4 - transitivePeerDependencies: - - supports-color - '@parcel/watcher-android-arm64@2.5.1': optional: true diff --git a/tests/cache/unit/cache-handler.test.ts b/tests/cache/unit/cache-handler.test.ts index 4501ff0..32a7b5c 100644 --- a/tests/cache/unit/cache-handler.test.ts +++ b/tests/cache/unit/cache-handler.test.ts @@ -248,6 +248,30 @@ describe("CacheHandler", () => { }); }); + describe("updateKey", () => { + it("should delegate updateKey to both layers", async () => { + const now = performance.timeOrigin + performance.now(); + const stream = Readable.toWeb(Readable.from("test")); + const entry: Entry = { + tags: ["tag1"], + timestamp: now, + stale: 0, + expire: 10, + revalidate: 5, + value: stream, + }; + + const spyEphemeral = jest.spyOn(handler.ephemeralLayer, "updateKey"); + const spyPersistent = jest.spyOn(handler.persistentLayer, "updateKey"); + + await handler.set("test-key", Promise.resolve(entry)); + await handler.updateKey("test-key", { expire: 20 }); + + expect(spyEphemeral).toHaveBeenCalledWith("test-key", { expire: 20 }); + expect(spyPersistent).toHaveBeenCalledWith("test-key", { expire: 20 }); + }); + }); + describe("getExpiration", () => { it("should return Infinity", async () => { const expiration = await handler.getExpiration(); diff --git a/tests/cache/unit/layers/fetch-layer.test.ts b/tests/cache/unit/layers/fetch-layer.test.ts index 42d03f1..d154e43 100644 --- a/tests/cache/unit/layers/fetch-layer.test.ts +++ b/tests/cache/unit/layers/fetch-layer.test.ts @@ -533,4 +533,42 @@ describe("FetchLayer", () => { expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/?key=test%2Fkey%3Awith%3Aspecial`, expect.anything()); }); }); + + describe("updateKey", () => { + it("should send PUT request with durations", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + }); + + await layer.updateKey("test-key", { expire: 20 }); + + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/?key=test-key`, + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ durations: { expire: 20 } }), + }), + ); + }); + + it("should log error when request fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + await layer.updateKey("test-key", { expire: 20 }); + + expect(mockLogger).toHaveBeenCalledWith( + expect.objectContaining({ + type: "UPDATE_KEY", + status: "ERROR", + source: "FETCH", + key: "test-key", + }), + ); + }); + }); }); diff --git a/tests/cache/unit/layers/fs-layer.test.ts b/tests/cache/unit/layers/fs-layer.test.ts index b59528d..26d5c12 100644 --- a/tests/cache/unit/layers/fs-layer.test.ts +++ b/tests/cache/unit/layers/fs-layer.test.ts @@ -266,6 +266,33 @@ describe("FsLayer", () => { }); }); + describe("updateKey", () => { + it("should update metadata for existing key", async () => { + const now = performance.timeOrigin + performance.now(); + const entry: Entry = { + tags: ["tag1"], + timestamp: now - 1000, + stale: 100, + expire: 10, + revalidate: 5, + value: createMockStream("content"), + }; + + await layer.set("test-key", entry); + await layer.updateKey("test-key", { expire: 20 }); + + const result = await layer.get("test-key"); + expect(result).toBeDefined(); + expect(result?.tags).toEqual(["tag1"]); + expect(result?.stale).toBe(0); + expect(result?.revalidate).toBe(20); + }); + + it("should handle missing metadata without throwing", async () => { + await expect(layer.updateKey("missing-key", { expire: 20 })).resolves.not.toThrow(); + }); + }); + describe("keys", () => { it("should return empty array when no keys exist", async () => { const keys = await layer.keys(); diff --git a/tests/cache/unit/layers/lru-layer.test.ts b/tests/cache/unit/layers/lru-layer.test.ts index b553365..1c4f7e5 100644 --- a/tests/cache/unit/layers/lru-layer.test.ts +++ b/tests/cache/unit/layers/lru-layer.test.ts @@ -239,4 +239,33 @@ describe("LruLayer", () => { expect(result?.stale).toBe(100); }); }); + + describe("updateKey", () => { + it("should update metadata for existing key", async () => { + const now = performance.timeOrigin + performance.now(); + const entry: Entry = { + tags: ["tag1"], + timestamp: now - 1000, + stale: 100, + expire: 10, + revalidate: 5, + value: createMockStream(), + }; + + await layer.set("test-key", entry); + await layer.updateKey("test-key", { expire: 20 }); + + const result = await layer.get("test-key"); + expect(result).toBeDefined(); + expect(result?.tags).toEqual(["tag1"]); + expect(result?.stale).toBe(0); + expect(result?.revalidate).toBe(20); + }); + + it("should not change anything for non-existent key", async () => { + await expect(layer.updateKey("missing-key", { expire: 20 })).resolves.toBeUndefined(); + const result = await layer.get("missing-key"); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/tests/cache/unit/layers/redis-layer.test.ts b/tests/cache/unit/layers/redis-layer.test.ts index 7b6bd5a..eeea9e9 100644 --- a/tests/cache/unit/layers/redis-layer.test.ts +++ b/tests/cache/unit/layers/redis-layer.test.ts @@ -332,4 +332,38 @@ describe("RedisLayer", () => { expect(result).toBeUndefined(); }); }); + + describe("updateKey", () => { + it("should update metadata for existing key", async () => { + const now = performance.timeOrigin + performance.now(); + const meta: Metadata = { + tags: ["tag1"], + timestamp: now - 1000, + stale: 100, + expire: 10, + revalidate: 5, + }; + + mockRedisClient.status = "ready"; + mockRedisClient.get.mockResolvedValueOnce(JSON.stringify(meta)); + + await layer.updateKey("test-key", { expire: 20 }); + + expect(mockRedisClient.get).toHaveBeenCalledWith("nic:meta:test-key"); + expect(mockRedisClient.set).toHaveBeenCalledWith( + "nic:meta:test-key", + expect.stringContaining('"revalidate":20'), + "EX", + expect.any(Number), + ); + }); + + it("should do nothing when meta entry is missing", async () => { + mockRedisClient.status = "ready"; + mockRedisClient.get.mockResolvedValueOnce(null); + + await expect(layer.updateKey("missing-key", { expire: 20 })).resolves.toBeUndefined(); + expect(mockRedisClient.set).not.toHaveBeenCalled(); + }); + }); });