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
10 changes: 2 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ export class CloudflareDOStateAdapter implements StateAdapter {
await this.stub().cacheSet(key, JSON.stringify(value), ttlMs);
}

async setIfNotExists<T = unknown>(
key: string,
value: T,
ttlMs?: number
): Promise<boolean> {
return this.stub().cacheSetIfNotExists(
key,
JSON.stringify(value),
ttlMs
);
}

async delete(key: string): Promise<void> {
await this.stub().cacheDelete(key);
}
Expand Down
34 changes: 34 additions & 0 deletions src/durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,40 @@ export class ChatStateDO<TEnv = unknown> extends DurableObject<TEnv> {
}
}

/**
* 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 {
Comment thread
Ehesp marked this conversation as resolved.
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);
}
Expand Down
31 changes: 30 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
/// <reference types="@cloudflare/workers-types" />
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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -132,7 +145,7 @@ function createMockNamespace() {
// ---------------------------------------------------------------------------

describe("CloudflareDOStateAdapter", () => {
let adapter: StateAdapter;
let adapter: AdapterUnderTest;
let mock: ReturnType<typeof createMockNamespace>;

beforeEach(async () => {
Expand Down Expand Up @@ -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 ------------------------------------------------------------
Expand Down
Loading