diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/index.ts b/dev-packages/cloudflare-integration-tests/suites/kv/index.ts new file mode 100644 index 000000000000..cae7fafc4abf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/index.ts @@ -0,0 +1,58 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + MY_KV: KVNamespace; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/kv/get') { + await env.MY_KV.put('get-key', 'get-value'); + const value = await env.MY_KV.get('get-key'); + return new Response(value); + } + + if (url.pathname === '/kv/bulk-get') { + await env.MY_KV.put('bulk-a', 'a'); + await env.MY_KV.put('bulk-b', 'b'); + const values = await env.MY_KV.get(['bulk-a', 'bulk-b']); + return Response.json(Object.fromEntries(values)); + } + + if (url.pathname === '/kv/get-with-metadata') { + await env.MY_KV.put('metadata-key', 'metadata-value', { metadata: { source: 'test' } }); + const value = await env.MY_KV.getWithMetadata('metadata-key'); + return Response.json(value); + } + + if (url.pathname === '/kv/put') { + await env.MY_KV.put('put-key', 'put-value'); + return new Response('OK'); + } + + if (url.pathname === '/kv/list') { + await env.MY_KV.put('list-a', 'a'); + await env.MY_KV.put('list-b', 'b'); + await env.MY_KV.list({ prefix: 'list-' }); + return new Response('OK'); + } + + if (url.pathname === '/kv/delete') { + await env.MY_KV.put('delete-key', 'gone'); + await env.MY_KV.delete('delete-key'); + return new Response('OK'); + } + + return new Response('not found', { status: 404 }); + }, + } as ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts new file mode 100644 index 000000000000..6629fa22f97a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts @@ -0,0 +1,163 @@ +import { + CACHE_HIT, + CACHE_KEY, + CACHE_OPERATION, + DB_COLLECTION_NAME, + DB_OPERATION_NAME, + DB_SYSTEM_NAME, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; +import { CACHE_OPERATION_NAMES } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; +import { getSpansFromEnvelope } from '../../spanUtils'; + +it('emits cache.get for a KV read with key and hit attributes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope).filter(span => span.name === CACHE_GET); + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + attributes: expect.objectContaining({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [CACHE_KEY]: { type: 'string', value: 'get-key' }, + [CACHE_HIT]: { type: 'boolean', value: true }, + [DB_OPERATION_NAME]: { type: 'string', value: 'get' }, + [DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' }, + [DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' }, + }), + }), + ]), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/get'); + await runner.completed(); +}); + +it('emits cache.get for a bulk KV read with all keys in cache.key', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope).filter( + span => span.name === CACHE_GET && span.attributes[DB_OPERATION_NAME]?.value === 'get', + ); + + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + attributes: expect.objectContaining({ + [CACHE_KEY]: expect.objectContaining({ + type: 'array', + value: expect.arrayContaining(['bulk-a', 'bulk-b']), + }), + [CACHE_HIT]: { type: 'boolean', value: true }, + }), + }), + ]), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/bulk-get'); + await runner.completed(); +}); + +it('emits cache.get for getWithMetadata', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope).filter( + span => span.name === CACHE_GET && span.attributes[DB_OPERATION_NAME]?.value === 'getWithMetadata', + ); + + expect(spans).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + [CACHE_KEY]: { type: 'string', value: 'metadata-key' }, + [CACHE_HIT]: { type: 'boolean', value: true }, + }), + }), + ]); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/get-with-metadata'); + await runner.completed(); +}); + +it('emits cache.put for KV writes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope).filter(span => span.name === CACHE_PUT); + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + attributes: expect.objectContaining({ + [SENTRY_OP]: { type: 'string', value: CACHE_PUT }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_PUT] }, + [CACHE_KEY]: { type: 'string', value: 'put-key' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'put' }, + }), + }), + ]), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/put'); + await runner.completed(); +}); + +it('emits cache.remove for KV deletes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope).filter(span => span.name === CACHE_REMOVE); + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + attributes: expect.objectContaining({ + [SENTRY_OP]: { type: 'string', value: CACHE_REMOVE }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_REMOVE] }, + [CACHE_KEY]: { type: 'string', value: 'delete-key' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'delete' }, + }), + }), + ]), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/delete'); + await runner.completed(); +}); + +it('emits cache.get for KV list operations', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope).filter( + span => span.name === CACHE_GET && span.attributes[DB_OPERATION_NAME]?.value === 'list', + ); + + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + attributes: expect.objectContaining({ + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [DB_OPERATION_NAME]: { type: 'string', value: 'list' }, + }), + }), + ]), + ); + expect(spans[0]?.attributes[CACHE_KEY]).toBeUndefined(); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/list'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc new file mode 100644 index 000000000000..14b15cd17aa4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "kv_namespaces": [ + { + "binding": "MY_KV", + "id": "00000000000000000000000000000000", + }, + ], +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 33b02b9bd74f..18cf562d95be 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -6,6 +6,7 @@ import { isD1Database, isDurableObjectNamespace, isJSRPC, + isKVNamespace, isQueue, isR2Bucket, } from '../../utils/isBinding'; @@ -14,6 +15,7 @@ import { appendRpcMeta } from '../../utils/rpcMeta'; import { createRpcPropagationResolver } from '../../utils/rpcPropagation'; import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace'; import { instrumentFetcher } from './instrumentFetcher'; +import { instrumentKV } from './instrumentKV'; import { instrumentQueueProducer } from './instrumentQueueProducer'; import { instrumentR2Bucket } from './instrumentR2'; @@ -31,6 +33,7 @@ const instrumentedBindings = new WeakMap(); * - DurableObjectNamespace (via `idFromName` duck-typing) * - Service bindings / JSRPC proxies * - Queue producers (via `send` + `sendBatch` duck-typing) + * - Workers KV namespaces (via `get` + `put` + `list` + `getWithMetadata` duck-typing) * - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing) * - Workers AI (via `run` + `gateway` + `toMarkdown` duck-typing) * @@ -71,6 +74,13 @@ export function instrumentEnv>(env: Env, opt return instrumented; } + if (isKVNamespace(item)) { + const bindingName = typeof prop === 'string' ? prop : String(prop); + const instrumented = instrumentKV(item, bindingName); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + if (isR2Bucket(item)) { const bindingName = typeof prop === 'string' ? prop : String(prop); const instrumented = instrumentR2Bucket(item, bindingName); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts b/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts new file mode 100644 index 000000000000..c070cd6974d4 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts @@ -0,0 +1,110 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; +import { + CACHE_HIT, + CACHE_KEY, + CACHE_OPERATION, + DB_COLLECTION_NAME, + DB_OPERATION_NAME, + DB_SYSTEM_NAME, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; +import { CACHE_OPERATION_NAMES, getClient, hasSpanStreamingEnabled, isObjectLike, startSpan } from '@sentry/core'; + +const ORIGIN = 'auto.cache.cloudflare.kv'; + +type KVOperation = 'get' | 'getWithMetadata' | 'put' | 'delete' | 'list'; + +const OPERATION_SPAN_OPS = { + get: CACHE_GET, + getWithMetadata: CACHE_GET, + put: CACHE_PUT, + delete: CACHE_REMOVE, + list: CACHE_GET, +} as const satisfies Record; + +function getCacheKeys(operation: KVOperation, args: unknown[]): string[] | undefined { + if (operation === 'list') { + return undefined; + } + + const key = args[0]; + + if (Array.isArray(key)) { + return key.map(value => String(value)); + } + + return typeof key === 'string' ? [key] : key == null ? undefined : [String(key)]; +} + +function createSpanOptions(bindingName: string, operation: KVOperation, args: unknown[]) { + const cacheOperation = OPERATION_SPAN_OPS[operation]; + const cacheOperationName = CACHE_OPERATION_NAMES[cacheOperation]; + const keys = getCacheKeys(operation, args); + const client = getClient(); + + return { + name: client && hasSpanStreamingEnabled(client) ? cacheOperation : keys?.join(', ') || operation, + attributes: { + [SENTRY_OP]: cacheOperation, + [CACHE_OPERATION]: cacheOperationName, + [SENTRY_ORIGIN]: ORIGIN, + ...(keys && keys.length > 0 && { [CACHE_KEY]: keys.length > 1 ? keys : keys[0] }), + [DB_OPERATION_NAME]: operation, + [DB_COLLECTION_NAME]: bindingName, + [DB_SYSTEM_NAME]: 'cloudflare-kv', + }, + }; +} + +function resolveCacheHit(operation: KVOperation, result: unknown): boolean { + if (operation === 'list') { + return false; + } + + if (result instanceof Map) { + return Array.from(result.values()).some(value => resolveCacheHit(operation, value)); + } + + if (operation === 'getWithMetadata' && isObjectLike(result) && 'value' in result) { + return result.value != null; + } + + return result != null; +} + +/** + * Wraps a Cloudflare Workers KV binding to create cache spans on namespace operations. + * + * Instrumented methods: get, getWithMetadata, put, delete, and list. + */ +export function instrumentKV(kv: T, bindingName: string): T { + return new Proxy(kv, { + get(target, prop, receiver) { + if (prop === 'get' || prop === 'getWithMetadata' || prop === 'put' || prop === 'delete' || prop === 'list') { + const original = Reflect.get(target, prop, receiver); + + if (typeof original !== 'function') { + return original; + } + + return function (this: unknown, ...args: unknown[]) { + const operation = prop as KVOperation; + + return startSpan(createSpanOptions(bindingName, operation, args), async span => { + const result = await Reflect.apply(original, target, args); + + if (operation === 'get' || operation === 'getWithMetadata') { + span.setAttribute(CACHE_HIT, resolveCacheHit(operation, result)); + } + + return result; + }); + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); +} diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index e675807b8fd7..1747d7346905 100644 --- a/packages/cloudflare/src/utils/isBinding.ts +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -31,7 +31,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { Ai, D1Database, DurableObjectNamespace, Queue, R2Bucket } from '@cloudflare/workers-types'; +import type { Ai, D1Database, DurableObjectNamespace, KVNamespace, Queue, R2Bucket } from '@cloudflare/workers-types'; /** * Checks if a value is a JSRPC proxy (service binding). @@ -96,6 +96,21 @@ export function isAiBinding(item: unknown): item is Ai { ); } +/** + * Duck-type check for Workers KV namespace bindings. + * KVNamespace has `get`, `put`, `list`, and `getWithMetadata` methods. + */ +export function isKVNamespace(item: unknown): item is KVNamespace { + return ( + item != null && + isNotJSRPC(item) && + typeof item.get === 'function' && + typeof item.put === 'function' && + typeof item.list === 'function' && + typeof item.getWithMetadata === 'function' + ); +} + /** * Duck-type check for R2 Bucket bindings. * R2Bucket has `head`, `put`, and `createMultipartUpload` methods. diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 9219091d53bd..297ea7d3db02 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -222,6 +222,43 @@ describe('instrumentEnv', () => { expect(send.mock.calls[0]?.[0]).toBe('hello'); }); + it('wraps Workers KV bindings in a proxy', async () => { + const get = vi.fn().mockResolvedValue('value'); + const put = vi.fn().mockResolvedValue(undefined); + const list = vi.fn().mockResolvedValue({ keys: [], list_complete: true }); + const getWithMetadata = vi.fn().mockResolvedValue({ value: 'value', metadata: null }); + const kv = { get, put, list, getWithMetadata, delete: vi.fn() }; + const env = { MY_KV: kv }; + const instrumented = instrumentEnv(env); + + const wrapped = instrumented.MY_KV as typeof kv; + expect(wrapped).not.toBe(kv); + + await wrapped.get('test-key', 'json'); + expect(get).toHaveBeenCalledWith('test-key', 'json'); + + await wrapped.put('test-key', 'value'); + expect(put).toHaveBeenCalledWith('test-key', 'value'); + }); + + it('caches the wrapped Workers KV binding across repeated access', () => { + const kv = { get: vi.fn(), put: vi.fn(), list: vi.fn(), getWithMetadata: vi.fn() }; + const env = { MY_KV: kv }; + const instrumented = instrumentEnv(env); + + expect(instrumented.MY_KV).toBe(instrumented.MY_KV); + }); + + it('instruments Workers KV bindings independently from R2 bindings', () => { + const kv = { get: vi.fn(), put: vi.fn(), list: vi.fn(), getWithMetadata: vi.fn() }; + const r2 = { head: vi.fn(), put: vi.fn(), createMultipartUpload: vi.fn() }; + const env = { MY_KV: kv, MY_BUCKET: r2 }; + const instrumented = instrumentEnv(env); + + expect(instrumented.MY_KV).not.toBe(kv); + expect(instrumented.MY_BUCKET).not.toBe(r2); + }); + it('caches the wrapped Queue binding across repeated access', () => { const queue = { send: vi.fn(), sendBatch: vi.fn() }; const env = { MY_QUEUE: queue }; diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts new file mode 100644 index 000000000000..f16da72d5a48 --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts @@ -0,0 +1,149 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; +import { + CACHE_KEY, + CACHE_OPERATION, + DB_COLLECTION_NAME, + DB_OPERATION_NAME, + DB_SYSTEM_NAME, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { instrumentKV } from '../../../src/instrumentations/worker/instrumentKV'; + +function createMockKVNamespace(): KVNamespace { + return { + get: vi.fn().mockResolvedValue('value'), + getWithMetadata: vi.fn().mockResolvedValue({ value: 'value', metadata: null }), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue({ keys: [], list_complete: true }), + } as unknown as KVNamespace; +} + +describe('instrumentKV', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + + test('forwards get with the type argument', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + const result = await wrapped.get('test-key', 'json'); + + expect(result).toBe('value'); + expect(kv.get).toHaveBeenCalledWith('test-key', 'json'); + }); + + test('starts a cache.get span with KV attributes', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + await wrapped.get('test-key'); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + [SENTRY_OP]: CACHE_GET, + [SENTRY_ORIGIN]: 'auto.cache.cloudflare.kv', + [CACHE_OPERATION]: SentryCore.CACHE_OPERATION_NAMES[CACHE_GET], + [CACHE_KEY]: 'test-key', + [DB_OPERATION_NAME]: 'get', + [DB_COLLECTION_NAME]: 'MY_KV', + [DB_SYSTEM_NAME]: 'cloudflare-kv', + }), + }), + expect.any(Function), + ); + }); + + test('handles bulk get without changing the underlying argument shape', async () => { + const kv = createMockKVNamespace(); + const get = vi.fn().mockResolvedValue(new Map([['first', 'one'], ['second', 'two']])); + kv.get = get as unknown as KVNamespace['get']; + const wrapped = instrumentKV(kv, 'MY_KV'); + + const keys = ['first', 'second']; + const result = await wrapped.get(keys, 'text'); + + expect(result).toBeInstanceOf(Map); + expect(get).toHaveBeenCalledWith(keys, 'text'); + expect(startSpanSpy.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + [CACHE_KEY]: ['first', 'second'], + }), + }), + ); + }); + + test('handles getWithMetadata as a cache read', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + await wrapped.getWithMetadata('test-key', 'json'); + + expect(kv.getWithMetadata).toHaveBeenCalledWith('test-key', 'json'); + expect(startSpanSpy.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + [SENTRY_OP]: CACHE_GET, + [CACHE_KEY]: 'test-key', + [DB_OPERATION_NAME]: 'getWithMetadata', + }), + }), + ); + }); + + test('instruments put, delete, and list operations', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + await wrapped.put('put-key', 'value'); + await wrapped.delete('delete-key'); + await wrapped.list({ prefix: 'test-' }); + + expect(startSpanSpy).toHaveBeenCalledTimes(3); + expect(startSpanSpy.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + [SENTRY_OP]: CACHE_PUT, + [CACHE_KEY]: 'put-key', + [DB_OPERATION_NAME]: 'put', + }), + }), + ); + expect(startSpanSpy.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + [SENTRY_OP]: CACHE_REMOVE, + [CACHE_KEY]: 'delete-key', + [DB_OPERATION_NAME]: 'delete', + }), + }), + ); + expect(startSpanSpy.mock.calls[2]?.[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + [SENTRY_OP]: CACHE_GET, + [DB_OPERATION_NAME]: 'list', + }), + }), + ); + expect(startSpanSpy.mock.calls[2]?.[0].attributes?.[CACHE_KEY]).toBeUndefined(); + }); + + test('forwards unknown property accesses transparently', () => { + const kv = Object.assign(createMockKVNamespace(), { + customMethod: vi.fn().mockReturnValue('ok'), + }) as KVNamespace & { customMethod: () => string }; + + const wrapped = instrumentKV(kv, 'MY_KV'); + expect(wrapped.customMethod()).toBe('ok'); + }); +}); diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts index 28d44fd9936d..ed92e2ccafbf 100644 --- a/packages/cloudflare/test/utils/isBinding.test.ts +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue } from '../../src/utils/isBinding'; +import { isD1Database, isDurableObjectNamespace, isJSRPC, isKVNamespace, isQueue } from '../../src/utils/isBinding'; describe('isJSRPC', () => { it('returns false for a plain object', () => { @@ -210,3 +210,41 @@ describe('isD1Database', () => { expect(isD1Database(jsrpcProxy)).toBe(false); }); }); + + +describe('isKVNamespace', () => { + it('returns true for an object with get, put, list, and getWithMetadata methods', () => { + const kv = { + get: async () => null, + put: async () => {}, + list: async () => ({ keys: [], list_complete: true }), + getWithMetadata: async () => ({ value: null, metadata: null }), + }; + expect(isKVNamespace(kv)).toBe(true); + }); + + it('returns false when getWithMetadata is missing', () => { + expect(isKVNamespace({ get: () => {}, put: () => {}, list: () => {} })).toBe(false); + }); + + it('returns false when a required method is not a function', () => { + expect(isKVNamespace({ get: 'nope', put: () => {}, list: () => {}, getWithMetadata: () => {} })).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(isKVNamespace(null)).toBe(false); + expect(isKVNamespace(undefined)).toBe(false); + }); + + it('returns false for a JSRPC proxy even though it has all KV methods', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isKVNamespace(jsrpcProxy)).toBe(false); + }); +});