diff --git a/package-lock.json b/package-lock.json index 319e5ef..69e5014 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chat-state-cloudflare-do", - "version": "0.1.8", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chat-state-cloudflare-do", - "version": "0.1.8", + "version": "0.1.1", "license": "MIT", "devDependencies": { "@cloudflare/workers-types": "^4.20250214.0", @@ -923,7 +923,6 @@ "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1280,7 +1279,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2369,7 +2367,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2419,7 +2416,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2808,7 +2804,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2946,7 +2941,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/adapter.ts b/src/adapter.ts index 68b2523..c652a66 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -128,6 +128,18 @@ export class CloudflareDOStateAdapter implements StateAdapter { await this.stub().cacheSet(key, JSON.stringify(value), ttlMs); } + async setIfNotExists( + key: string, + value: T, + ttlMs?: number + ): Promise { + return this.stub().cacheSetIfNotExists( + key, + JSON.stringify(value), + ttlMs + ); + } + async delete(key: string): Promise { await this.stub().cacheDelete(key); } diff --git a/src/durable-object.ts b/src/durable-object.ts index fa4cc06..ea615b3 100644 --- a/src/durable-object.ts +++ b/src/durable-object.ts @@ -210,6 +210,40 @@ export class ChatStateDO extends DurableObject { } } + /** + * Set the key only if it does not exist (or is expired). Returns true if + * the value was set, false if the key already existed and is not expired. + */ + cacheSetIfNotExists(key: string, value: string, ttlMs?: number): boolean { + const now = Date.now(); + const result = this.ctx.storage.transactionSync(() => { + const existing = this.sql + .exec( + "SELECT 1 FROM cache WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)", + key, + now + ) + .toArray(); + if (existing.length > 0) { + return { inserted: false, expiresAt: null as number | null }; + } + const expiresAt = ttlMs ? Date.now() + ttlMs : null; + this.sql.exec( + "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?)", + key, + value, + expiresAt + ); + return { inserted: true, expiresAt }; + }); + + // Schedule alarm outside the transaction — same pattern as acquireLock(). + if (result.inserted && result.expiresAt != null) { + this.scheduleCleanupIfNeeded(); + } + return result.inserted; + } + cacheDelete(key: string): void { this.sql.exec("DELETE FROM cache WHERE key = ?", key); } diff --git a/src/index.test.ts b/src/index.test.ts index 7c45608..23adfae 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,8 +1,13 @@ +/// import type { Lock, StateAdapter } from "chat"; import { beforeEach, describe, expect, it } from "vitest"; import { CloudflareDOStateAdapter } from "./adapter"; import { createCloudflareState } from "./index"; +// Use concrete adapter type so tests can call methods added in newer Chat SDK +// (e.g. setIfNotExists in 4.18) even when devDependency is an older version. +type AdapterUnderTest = CloudflareDOStateAdapter; + // --------------------------------------------------------------------------- // Mock DO — mirrors ChatStateDO behavior using in-memory data structures. // This lets us test the adapter's delegation logic, sharding, and @@ -93,6 +98,14 @@ class MockChatStateDO { }); } + cacheSetIfNotExists(key: string, value: string, ttlMs?: number): boolean { + if (this.cacheGet(key) !== null) { + return false; + } + this.cacheSet(key, value, ttlMs); + return true; + } + cacheDelete(key: string): void { this.cache.delete(key); } @@ -132,7 +145,7 @@ function createMockNamespace() { // --------------------------------------------------------------------------- describe("CloudflareDOStateAdapter", () => { - let adapter: StateAdapter; + let adapter: AdapterUnderTest; let mock: ReturnType; beforeEach(async () => { @@ -371,6 +384,22 @@ describe("CloudflareDOStateAdapter", () => { expect(await adapter.get("zero-ttl")).toBe("value"); }); + + it("should setIfNotExists only when key is missing or expired", async () => { + const set1 = await adapter.setIfNotExists("nx-key", "first"); + expect(set1).toBe(true); + expect(await adapter.get("nx-key")).toBe("first"); + + const set2 = await adapter.setIfNotExists("nx-key", "second"); + expect(set2).toBe(false); + expect(await adapter.get("nx-key")).toBe("first"); + + await adapter.set("expiring-nx", "old", 10); + await new Promise((resolve) => setTimeout(resolve, 20)); + const set3 = await adapter.setIfNotExists("expiring-nx", "new"); + expect(set3).toBe(true); + expect(await adapter.get("expiring-nx")).toBe("new"); + }); }); // -- Sharding ------------------------------------------------------------