From 7329a2982ff540aa8d6c4671f83e41fa5e1cdcdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Filho?= Date: Tue, 20 Jan 2026 17:49:36 -0300 Subject: [PATCH] feat: add KV package with dual provider support - Add new KV package with Redis-like client interface - Implement dual provider strategy (native and API) - Add automatic provider detection based on runtime environment - Implement core operations: get, getWithMetadata, set, delete - Add Redis-compatible methods: hSet, HSET, hGetAll, HGETALL, hVals, HVALS - Add support for metadata and expiration options - Export KV package in main package.json with CommonJS and ESM support --- package.json | 7 + packages/kv/.gitignore | 2 + packages/kv/README.md | 133 ++++ packages/kv/USAGE.md | 143 ++++ packages/kv/__tests__/client.test.ts | 736 ++++++++++++++++++ .../kv/__tests__/providers/native.test.ts | 413 ++++++++++ packages/kv/jest.config.ts | 10 + packages/kv/package.json | 29 + packages/kv/src/client.ts | 171 ++++ packages/kv/src/errors.ts | 27 + packages/kv/src/index.ts | 3 + packages/kv/src/providers/api.ts | 121 +++ packages/kv/src/providers/index.ts | 3 + packages/kv/src/providers/native.ts | 94 +++ packages/kv/src/providers/types.ts | 46 ++ packages/kv/src/types.ts | 35 + packages/kv/tsconfig.json | 11 + 17 files changed, 1984 insertions(+) create mode 100644 packages/kv/.gitignore create mode 100644 packages/kv/README.md create mode 100644 packages/kv/USAGE.md create mode 100644 packages/kv/__tests__/client.test.ts create mode 100644 packages/kv/__tests__/providers/native.test.ts create mode 100644 packages/kv/jest.config.ts create mode 100644 packages/kv/package.json create mode 100644 packages/kv/src/client.ts create mode 100644 packages/kv/src/errors.ts create mode 100644 packages/kv/src/index.ts create mode 100644 packages/kv/src/providers/api.ts create mode 100644 packages/kv/src/providers/index.ts create mode 100644 packages/kv/src/providers/native.ts create mode 100644 packages/kv/src/providers/types.ts create mode 100644 packages/kv/src/types.ts create mode 100644 packages/kv/tsconfig.json diff --git a/package.json b/package.json index 98d3c53e..a1be1214 100644 --- a/package.json +++ b/package.json @@ -171,6 +171,10 @@ "./domains": { "require": "./packages/domains/dist/index.js", "import": "./packages/domains/dist/index.mjs" + }, + "./kv": { + "require": "./packages/kv/dist/index.js", + "import": "./packages/kv/dist/index.mjs" } }, "typesVersions": { @@ -237,6 +241,9 @@ ], "utils/node": [ "./packages/utils/dist/node/index.d.ts" + ], + "kv": [ + "./packages/kv/dist/index.d.ts" ] } }, diff --git a/packages/kv/.gitignore b/packages/kv/.gitignore new file mode 100644 index 00000000..76add878 --- /dev/null +++ b/packages/kv/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/packages/kv/README.md b/packages/kv/README.md new file mode 100644 index 00000000..f99584f0 --- /dev/null +++ b/packages/kv/README.md @@ -0,0 +1,133 @@ +# Azion KV + +A Redis-like KV (Key-Value) client library for Azion Platform. + +## Installation + +```bash +npm install azion +``` + +## Usage + +### Creating a Client + +```typescript +import { createClient, KVClient } from 'azion/kv'; + +// Create a client with Redis-like chaining pattern +const client = await createClient() + .on('error', (err) => console.log('KV Client Error', err)) + .connect(); + +// Or with custom options +const client = await createClient({ + namespace: 'my-namespace', + apiToken: 'my-token', +}) + .on('error', (err) => console.error('KV Error:', err)) + .connect(); + +// Get a value +const value = await client.get('my-key'); + +// Set a value +await client.set('my-key', 'my-value'); + +// Delete a value +await client.delete('my-key'); + +// Disconnect when done +await client.disconnect(); +``` + +### Basic Operations + +#### Get + +```typescript +// Simple get +const value = await client.get('my-key'); + +// Get with metadata +const result = await client.getWithMetadata('my-key'); +console.log(result.value, result.metadata); +``` + +#### Set + +```typescript +// Simple set +await client.set('my-key', 'my-value'); + +// Set with options +await client.set('my-key', 'my-value', { + expiration: { + type: 'EX', + value: 10, // 10 seconds + }, + metadata: { userId: '123' }, +}); +``` + +#### Delete + +```typescript +await client.delete('my-key'); +// or +await client.del('my-key'); +``` + +#### hSet and HSET + +```typescript +await client.hSet('my-key', 'field', 'value'); +await client.HSET('my-key', 'field', 'value'); +``` + +#### hGetAll and HGETALL + +```typescript +const result = await client.hGetAll('my-key'); +const result = await client.HGETALL('my-key'); +``` + +#### hVals and HVALS + +```typescript +const result = await client.hVals('my-key'); +const result = await client.HVALS('my-key'); +``` + +### API Reference + +#### KVClient + +Main client class for interacting with Azion KV. + +#### Methods + +- `createClient(options?: KVClientOptions): KVClient` - Create a new KV client (does not auto-connect) +- `on(event: 'error', handler: (error: Error) => void): this` - Register error event handler (chainable) +- `connect(): Promise` - Connect to KV store (chainable) +- `get(key: string, options?: KVGetOptions): Promise` +- `getWithMetadata(key: string, options?: KVGetOptions): Promise` +- `set(key: string, value: KVValue, options?: KVSetOptions): Promise` +- `delete(key: string): Promise` +- `disconnect(): Promise` +- `quit(): Promise` +- `hSet(key: string, field: string, value: KVValue): Promise` +- `HSET(key: string, field: string, value: KVValue): Promise` +- `hGetAll(key: string): Promise` +- `HGETALL(key: string): Promise` +- `hVals(key: string): Promise` +- `HVALS(key: string): Promise` +- `getProviderType(): 'native' | 'api'` + +### Types + +See `src/types.ts` for complete type definitions. + +## License + +MIT diff --git a/packages/kv/USAGE.md b/packages/kv/USAGE.md new file mode 100644 index 00000000..5492887f --- /dev/null +++ b/packages/kv/USAGE.md @@ -0,0 +1,143 @@ +# Azion KV Usage Guide + +## Dual Implementation Strategy + +The KV package automatically detects if `globalThis.Azion.KV` is available and chooses the appropriate implementation: + +- **Native Provider**: Uses `globalThis.Azion.KV` when available (Edge Runtime) +- **API Provider**: Uses Azion's REST API as fallback + +## Basic Usage + +### 1. Auto-Detection + +```typescript +import { createClient } from 'azion/kv'; + +// Automatically detects which provider to use (Redis-like pattern) +const client = await createClient() + .on('error', (err) => console.log('KV Client Error', err)) + .connect(); + +// On Edge Runtime with Azion.KV available -> uses Native Provider +// In other environments -> uses API Provider +``` + +### 2. Force Native Provider + +```typescript +import { createClient } from 'azion/kv'; + +const client = await createClient({ + provider: 'native', +}) + .on('error', (err) => console.error(err)) + .connect(); + +// Always uses globalThis.Azion.KV +// Throws error if not available +``` + +### 3. Force API Provider + +```typescript +import { createClient } from 'azion/kv'; + +const client = await createClient({ + provider: 'api', + apiToken: 'your-token-here', + environment: 'production', // or 'stage' +}) + .on('error', (err) => console.error(err)) + .connect(); +``` + +### 4. Check Which Provider is Being Used + +```typescript +const client = await createClient() + .on('error', (err) => console.error(err)) + .connect(); + +console.log(client.getProviderType()); // 'native' or 'api' +``` + +## Operations + +### GET + +```typescript +// Simple get +const value = await client.get('user:123'); + +// Get with metadata +const result = await client.getWithMetadata('user:123'); +console.log(result.value); +console.log(result.metadata); + +// Get with specific type +const json = await client.get('user:123', { type: 'json' }); +const buffer = await client.get('file:data', { type: 'arrayBuffer' }); +``` + +### SET + +```typescript +// Simple set +await client.set('user:123', 'John Doe'); + +// Set with options +await client.set('user:123', JSON.stringify({ name: 'John' }), { + expiration: { + type: 'EX', + value: 10, // 10 seconds + }, + metadata: { created: Date.now() }, +}); +``` + +### DELETE + +```typescript +await client.delete('user:123'); +// or +await client.del('user:123'); +``` + +## Environment Configuration + +### Edge Runtime (Azion) + +```typescript +// Automatically uses globalThis.Azion.KV +const client = await createClient() + .on('error', (err) => console.error(err)) + .connect(); +``` + +### Local Development / CI/CD + +```typescript +// Uses API with credentials +const client = await createClient({ + provider: 'api', + apiToken: process.env.AZION_API_TOKEN, + environment: 'stage', + namespace: 'dev', +}) + .on('error', (err) => console.error(err)) + .connect(); +``` + +### Example with Environment Variables + +```typescript +const client = await createClient({ + provider: process.env.KV_PROVIDER as 'auto' | 'native' | 'api', + apiToken: process.env.AZION_API_TOKEN, + environment: process.env.AZION_ENV as 'production' | 'stage', + namespace: process.env.AZION_KV_NAMESPACE, +}) + .on('error', (err) => console.error(err)) + .connect(); +``` diff --git a/packages/kv/__tests__/client.test.ts b/packages/kv/__tests__/client.test.ts new file mode 100644 index 00000000..978bca70 --- /dev/null +++ b/packages/kv/__tests__/client.test.ts @@ -0,0 +1,736 @@ +import { KVClient, createClient } from '../src/client'; +import { APIKVProvider } from '../src/providers/api'; +import { NativeKVProvider } from '../src/providers/native'; + +jest.mock('../src/providers/native'); +jest.mock('../src/providers/api'); + +describe('KVClient', () => { + const mockNativeProvider = { + get: jest.fn(), + getWithMetadata: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + + const mockAPIProvider = { + get: jest.fn(), + getWithMetadata: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.AZION_KV_NAMESPACE; + + // Mock NativeKVProvider.create static method + (NativeKVProvider.create as jest.Mock) = jest + .fn() + .mockResolvedValue(Object.assign(Object.create(NativeKVProvider.prototype), mockNativeProvider)); + + // Mock APIKVProvider constructor + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (APIKVProvider as jest.MockedClass).mockImplementation(function (this: any) { + Object.setPrototypeOf(this, APIKVProvider.prototype); + return Object.assign(this, mockAPIProvider); + }); + }); + + describe('constructor and createProvider', () => { + it('should create client with default options', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + process.env.AZION_KV_NAMESPACE = 'test-ns'; + + const client = await createClient().connect(); + + expect(client).toBeInstanceOf(KVClient); + expect(APIKVProvider).toHaveBeenCalledWith({ + apiToken: undefined, + namespace: 'test-ns', + environment: undefined, + }); + }); + + it('should use native provider when available and provider is auto', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(true); + (NativeKVProvider.create as jest.Mock).mockResolvedValue( + Object.assign(Object.create(NativeKVProvider.prototype), mockNativeProvider), + ); + + const client = await createClient({ namespace: 'test-ns' }).connect(); + + expect(NativeKVProvider.create).toHaveBeenCalledWith('test-ns'); + expect(client.getProviderType()).toBe('native'); + }); + + it('should use API provider when native is not available', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + + const client = await createClient({ + namespace: 'test-ns', + apiToken: 'token-123', + environment: 'production', + }).connect(); + + expect(APIKVProvider).toHaveBeenCalledWith({ + apiToken: 'token-123', + namespace: 'test-ns', + environment: 'production', + }); + expect(client.getProviderType()).toBe('api'); + }); + + it('should force native provider when specified', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(true); + (NativeKVProvider.create as jest.Mock).mockResolvedValue( + Object.assign(Object.create(NativeKVProvider.prototype), mockNativeProvider), + ); + + const client = await createClient({ + provider: 'native', + namespace: 'test-ns', + }).connect(); + + expect(NativeKVProvider.create).toHaveBeenCalledWith('test-ns'); + expect(client.getProviderType()).toBe('native'); + }); + + it('should force API provider when specified', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(true); + + const client = await createClient({ + provider: 'api', + namespace: 'test-ns', + apiToken: 'token-123', + }).connect(); + + expect(APIKVProvider).toHaveBeenCalledWith({ + apiToken: 'token-123', + namespace: 'test-ns', + environment: undefined, + }); + expect(client.getProviderType()).toBe('api'); + }); + + it('should use namespace from environment variable if not provided', async () => { + process.env.AZION_KV_NAMESPACE = 'env-namespace'; + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(true); + (NativeKVProvider.create as jest.Mock).mockResolvedValue(mockNativeProvider); + + await createClient().connect(); + + expect(NativeKVProvider.create).toHaveBeenCalledWith('env-namespace'); + }); + + it('should prefer options namespace over environment variable', async () => { + process.env.AZION_KV_NAMESPACE = 'env-namespace'; + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(true); + (NativeKVProvider.create as jest.Mock).mockResolvedValue(mockNativeProvider); + + await createClient({ namespace: 'options-namespace' }).connect(); + + expect(NativeKVProvider.create).toHaveBeenCalledWith('options-namespace'); + }); + + it('should throw error when namespace undefined', async () => { + await expect(createClient().connect()).rejects.toThrow('namespace is required'); + }); + }); + + describe('get', () => { + it('should delegate get to provider', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + mockAPIProvider.get.mockResolvedValue('test-value'); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.get('test-key'); + + expect(mockAPIProvider.get).toHaveBeenCalledWith('test-key', undefined); + expect(result).toBe('test-value'); + }); + + it('should delegate get with options to provider', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + mockAPIProvider.get.mockResolvedValue({ data: 'test' }); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.get('test-key', { type: 'json', cacheTtl: 3600 }); + + expect(mockAPIProvider.get).toHaveBeenCalledWith('test-key', { + type: 'json', + cacheTtl: 3600, + }); + expect(result).toEqual({ data: 'test' }); + }); + + it('should return null when key does not exist', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + mockAPIProvider.get.mockResolvedValue(null); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.get('non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('getWithMetadata', () => { + it('should delegate getWithMetadata to provider', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + const mockResult = { + value: 'test-value', + metadata: { userId: '123' }, + }; + mockAPIProvider.getWithMetadata.mockResolvedValue(mockResult); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.getWithMetadata('test-key'); + + expect(mockAPIProvider.getWithMetadata).toHaveBeenCalledWith('test-key', undefined); + expect(result).toEqual(mockResult); + }); + + it('should delegate getWithMetadata with options to provider', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + const mockResult = { + value: { data: 'test' }, + metadata: { version: 1 }, + }; + mockAPIProvider.getWithMetadata.mockResolvedValue(mockResult); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.getWithMetadata('test-key', { + type: 'json', + cacheTtl: 7200, + }); + + expect(mockAPIProvider.getWithMetadata).toHaveBeenCalledWith('test-key', { + type: 'json', + cacheTtl: 7200, + }); + expect(result).toEqual(mockResult); + }); + }); + + describe('set', () => { + it('should delegate set to provider', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + mockAPIProvider.set.mockResolvedValue(undefined); + + const client = await createClient({ namespace: 'test' }).connect(); + await client.set('test-key', 'test-value'); + + expect(mockAPIProvider.set).toHaveBeenCalledWith('test-key', 'test-value', undefined); + }); + + it('should delegate set with options to provider', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + mockAPIProvider.set.mockResolvedValue(undefined); + + const client = await createClient({ namespace: 'test' }).connect(); + await client.set('test-key', 'test-value', { + expirationTtl: 3600, + metadata: { userId: '123' }, + }); + + expect(mockAPIProvider.set).toHaveBeenCalledWith('test-key', 'test-value', { + expirationTtl: 3600, + metadata: { userId: '123' }, + }); + }); + + it('should set ArrayBuffer value', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + mockAPIProvider.set.mockResolvedValue(undefined); + const buffer = new ArrayBuffer(8); + + const client = await createClient({ namespace: 'test' }).connect(); + await client.set('test-key', buffer); + + expect(mockAPIProvider.set).toHaveBeenCalledWith('test-key', buffer, undefined); + }); + + it('should set with expiration timestamp', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + mockAPIProvider.set.mockResolvedValue(undefined); + + const client = await createClient({ namespace: 'test' }).connect(); + await client.set('test-key', 'test-value', { + expiration: { + type: 'EX', + value: 1234567890, + }, + }); + + expect(mockAPIProvider.set).toHaveBeenCalledWith('test-key', 'test-value', { + expiration: { + type: 'EX', + value: 1234567890, + }, + }); + }); + }); + + describe('delete', () => { + it('should delegate delete to provider', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + mockAPIProvider.delete.mockResolvedValue(undefined); + + const client = await createClient({ namespace: 'test' }).connect(); + await client.delete('test-key'); + + expect(mockAPIProvider.delete).toHaveBeenCalledWith('test-key'); + }); + + it('should handle deleting non-existent key', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + mockAPIProvider.delete.mockResolvedValue(undefined); + + const client = await createClient({ namespace: 'test' }).connect(); + await expect(client.delete('non-existent')).resolves.not.toThrow(); + }); + }); + + describe('hSet and HSET', () => { + beforeEach(() => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + }); + + it('should create new hash with single field', async () => { + mockAPIProvider.get.mockResolvedValue(null); + mockAPIProvider.set.mockResolvedValue(undefined); + + const client = await createClient({ namespace: 'test' }).connect(); + await client.hSet('user:123', 'name', 'John Doe'); + await client.HSET('user:123', 'email', 'john@example.com'); + + expect(mockAPIProvider.get).toHaveBeenCalledWith('user:123'); + expect(mockAPIProvider.set).toHaveBeenCalledWith('user:123', JSON.stringify({ name: 'John Doe' })); + expect(mockAPIProvider.set).toHaveBeenCalledWith('user:123', JSON.stringify({ name: 'John Doe' })); + }); + + it('should add field to existing hash', async () => { + mockAPIProvider.get.mockResolvedValue(JSON.stringify({ name: 'John Doe' })); + mockAPIProvider.set.mockResolvedValue(undefined); + + const client = await createClient({ namespace: 'test' }).connect(); + await client.hSet('user:123', 'email', 'john@example.com'); + await client.HSET('user:123', 'email2', 'john@example.com'); + + expect(mockAPIProvider.get).toHaveBeenCalledWith('user:123'); + expect(mockAPIProvider.set).toHaveBeenCalledWith( + 'user:123', + JSON.stringify({ name: 'John Doe', email: 'john@example.com' }), + ); + expect(mockAPIProvider.set).toHaveBeenCalledWith( + 'user:123', + JSON.stringify({ name: 'John Doe', email2: 'john@example.com' }), + ); + }); + + it('should update existing field in hash', async () => { + mockAPIProvider.get.mockResolvedValueOnce(JSON.stringify({ name: 'John Doe', email: 'old@example.com' })); + mockAPIProvider.get.mockResolvedValueOnce(JSON.stringify({ name: 'John Doe', email: 'new@example.com' })); + mockAPIProvider.set.mockResolvedValue(undefined); + + const client = await createClient({ namespace: 'test' }).connect(); + await client.hSet('user:123', 'email', 'new@example.com'); + await client.HSET('user:123', 'email2', 'new2@example.com'); + + expect(mockAPIProvider.set).toHaveBeenCalledWith( + 'user:123', + JSON.stringify({ name: 'John Doe', email: 'new@example.com' }), + ); + expect(mockAPIProvider.set).toHaveBeenCalledWith( + 'user:123', + JSON.stringify({ name: 'John Doe', email: 'new@example.com', email2: 'new2@example.com' }), + ); + }); + + it('should handle invalid JSON in existing value', async () => { + mockAPIProvider.get.mockResolvedValue('invalid-json'); + mockAPIProvider.set.mockResolvedValue(undefined); + + const client = await createClient({ namespace: 'test' }).connect(); + await client.hSet('user:123', 'name', 'John Doe'); + await client.HSET('user:123', 'name2', 'John Doe2'); + + expect(mockAPIProvider.set).toHaveBeenCalledWith('user:123', JSON.stringify({ name: 'John Doe' })); + expect(mockAPIProvider.set).toHaveBeenCalledWith('user:123', JSON.stringify({ name2: 'John Doe2' })); + }); + + it('should handle multiple sequential hSet calls', async () => { + const client = await createClient({ namespace: 'test' }).connect(); + + // First hSet: get returns null + mockAPIProvider.get.mockResolvedValueOnce(null); + // Second HSET: get returns result from first hSet + mockAPIProvider.get.mockResolvedValueOnce(JSON.stringify({ name: 'John Doe' })); + // Third hSet: get returns result from first two calls + mockAPIProvider.get.mockResolvedValueOnce(JSON.stringify({ name: 'John Doe', name2: 'John Doe2' })); + // Fourth HSET: get returns result from first three calls + mockAPIProvider.get.mockResolvedValueOnce( + JSON.stringify({ name: 'John Doe', name2: 'John Doe2', email: 'john@example.com' }), + ); + // Fifth hSet: get returns result from first four calls + mockAPIProvider.get.mockResolvedValueOnce( + JSON.stringify({ + name: 'John Doe', + name2: 'John Doe2', + email: 'john@example.com', + email2: 'john@example.com2', + }), + ); + // Sixth HSET: get returns result from first five calls + mockAPIProvider.get.mockResolvedValueOnce( + JSON.stringify({ + name: 'John Doe', + name2: 'John Doe2', + email: 'john@example.com', + email2: 'john@example.com2', + age: '30', + }), + ); + + mockAPIProvider.set.mockResolvedValue(undefined); + + await client.hSet('user:123', 'name', 'John Doe'); + await client.HSET('user:123', 'name2', 'John Doe2'); + await client.hSet('user:123', 'email', 'john@example.com'); + await client.HSET('user:123', 'email2', 'john@example.com2'); + await client.hSet('user:123', 'age', '30'); + await client.HSET('user:123', 'age2', '302'); + + expect(mockAPIProvider.set).toHaveBeenNthCalledWith( + 3, + 'user:123', + JSON.stringify({ name: 'John Doe', name2: 'John Doe2', email: 'john@example.com' }), + ); + expect(mockAPIProvider.set).toHaveBeenNthCalledWith( + 6, + 'user:123', + JSON.stringify({ + name: 'John Doe', + name2: 'John Doe2', + email: 'john@example.com', + email2: 'john@example.com2', + age: '30', + age2: '302', + }), + ); + }); + + it('should handle different value types', async () => { + mockAPIProvider.get.mockResolvedValue(null); + mockAPIProvider.set.mockResolvedValue(undefined); + + const client = await createClient({ namespace: 'test' }).connect(); + await client.hSet('data:123', 'buffer', new ArrayBuffer(8)); + + const callArgs = mockAPIProvider.set.mock.calls[0]; + const savedValue = JSON.parse(callArgs[1]); + expect(savedValue).toHaveProperty('buffer'); + }); + }); + + describe('hGetAll and HGETALL', () => { + beforeEach(() => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + }); + + it('should return all fields from hash using hGetAll', async () => { + const hashData = { + name: 'John Doe', + email: 'john@example.com', + age: '30', + }; + mockAPIProvider.get.mockResolvedValue(JSON.stringify(hashData)); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.hGetAll('user:123'); + + expect(mockAPIProvider.get).toHaveBeenCalledWith('user:123'); + expect(result).toEqual(hashData); + }); + + it('should return all fields from hash using HGETALL', async () => { + const hashData = { + name: 'John Doe', + email: 'john@example.com', + age: '30', + }; + mockAPIProvider.get.mockResolvedValue(JSON.stringify(hashData)); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.HGETALL('user:123'); + + expect(mockAPIProvider.get).toHaveBeenCalledWith('user:123'); + expect(result).toEqual(hashData); + }); + + it('should return null when key does not exist', async () => { + mockAPIProvider.get.mockResolvedValue(null); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.hGetAll('non-existent'); + + expect(result).toBeNull(); + }); + + it('should return null when value is not valid JSON', async () => { + mockAPIProvider.get.mockResolvedValue('invalid-json'); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.hGetAll('user:123'); + + expect(result).toBeNull(); + }); + + it('should return empty hash', async () => { + mockAPIProvider.get.mockResolvedValue(JSON.stringify({})); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.hGetAll('user:123'); + + expect(result).toEqual({}); + }); + + it('should handle complex nested values', async () => { + const hashData = { + user: JSON.stringify({ name: 'John', age: 30 }), + metadata: JSON.stringify({ created: 1234567890 }), + tags: JSON.stringify(['tag1', 'tag2']), + }; + mockAPIProvider.get.mockResolvedValue(JSON.stringify(hashData)); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.hGetAll('data:123'); + + expect(result).toEqual(hashData); + }); + }); + + describe('hVals and HVALS', () => { + beforeEach(() => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + }); + + it('should return all values from hash using hVals', async () => { + const hashData = { + name: 'John Doe', + email: 'john@example.com', + age: '30', + }; + mockAPIProvider.get.mockResolvedValue(JSON.stringify(hashData)); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.hVals('user:123'); + + expect(mockAPIProvider.get).toHaveBeenCalledWith('user:123'); + expect(result).toEqual(['John Doe', 'john@example.com', '30']); + }); + + it('should return all values from hash using HVALS', async () => { + const hashData = { + name: 'John Doe', + email: 'john@example.com', + age: '30', + }; + mockAPIProvider.get.mockResolvedValue(JSON.stringify(hashData)); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.HVALS('user:123'); + + expect(mockAPIProvider.get).toHaveBeenCalledWith('user:123'); + expect(result).toEqual(['John Doe', 'john@example.com', '30']); + }); + + it('should return null when key does not exist', async () => { + mockAPIProvider.get.mockResolvedValue(null); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.hVals('non-existent'); + + expect(result).toBeNull(); + }); + + it('should return null when value is not valid JSON', async () => { + mockAPIProvider.get.mockResolvedValue('invalid-json'); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.hVals('user:123'); + + expect(result).toBeNull(); + }); + + it('should return empty array for empty hash', async () => { + mockAPIProvider.get.mockResolvedValue(JSON.stringify({})); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.hVals('user:123'); + + expect(result).toEqual([]); + }); + + it('should handle single value', async () => { + const hashData = { + name: 'John Doe', + }; + mockAPIProvider.get.mockResolvedValue(JSON.stringify(hashData)); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.hVals('user:123'); + + expect(result).toEqual(['John Doe']); + }); + + it('should preserve value types', async () => { + mockAPIProvider.get.mockReset(); + const hashData = { + name: 'John Doe', + age: 30, + active: true, + metadata: { created: 1234567890 }, + }; + mockAPIProvider.get.mockResolvedValue(JSON.stringify(hashData)); + + const client = await createClient({ namespace: 'test' }).connect(); + const result = await client.hVals('user:123'); + + expect(result).toEqual(['John Doe', 30, true, { created: 1234567890 }]); + }); + }); + + describe('getProviderType', () => { + it('should return native when using NativeKVProvider', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(true); + (NativeKVProvider.create as jest.Mock).mockResolvedValue( + Object.assign(Object.create(NativeKVProvider.prototype), mockNativeProvider), + ); + + const client = await createClient({ namespace: 'test' }).connect(); + + expect(client.getProviderType()).toBe('native'); + }); + + it('should return api when using APIKVProvider', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + + const client = await createClient({ namespace: 'test' }).connect(); + + expect(client.getProviderType()).toBe('api'); + }); + }); + + describe('disconnect and quit', () => { + it('should disconnect successfully', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + + const client = await createClient({ namespace: 'test' }).connect(); + await expect(client.disconnect()).resolves.toBeUndefined(); + }); + + it('should quit successfully', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + + const client = await createClient({ namespace: 'test' }).connect(); + await expect(client.quit()).resolves.toBeUndefined(); + }); + + it('quit should call disconnect', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + + const client = await createClient({ namespace: 'test' }).connect(); + const disconnectSpy = jest.spyOn(client, 'disconnect'); + + await client.quit(); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + }); + + describe('createClient factory function', () => { + it('should create client instance', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + + const client = await createClient({ namespace: 'test-ns' }).connect(); + + expect(client).toBeInstanceOf(KVClient); + }); + + it('should create client with options', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + + const client = await createClient({ + namespace: 'test-ns', + apiToken: 'token-123', + environment: 'production', + }).connect(); + + expect(client).toBeInstanceOf(KVClient); + expect(APIKVProvider).toHaveBeenCalledWith({ + apiToken: 'token-123', + namespace: 'test-ns', + environment: 'production', + }); + }); + }); + + describe('integration scenarios', () => { + it('should work with native provider in edge runtime', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(true); + (NativeKVProvider.create as jest.Mock).mockResolvedValue(mockNativeProvider); + mockNativeProvider.get.mockResolvedValue('edge-value'); + + const client = await createClient({ namespace: 'edge-ns' }).connect(); + const result = await client.get('edge-key'); + + expect(NativeKVProvider.create).toHaveBeenCalledWith('edge-ns'); + expect(mockNativeProvider.get).toHaveBeenCalledWith('edge-key', undefined); + expect(result).toBe('edge-value'); + }); + + it('should fallback to API provider when native is unavailable', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + mockAPIProvider.get.mockReset(); + mockAPIProvider.get.mockResolvedValue('api-value'); + + const client = await createClient({ + namespace: 'api-ns', + apiToken: 'token-123', + }).connect(); + const result = await client.get('api-key'); + + expect(APIKVProvider).toHaveBeenCalledWith({ + apiToken: 'token-123', + namespace: 'api-ns', + environment: undefined, + }); + expect(mockAPIProvider.get).toHaveBeenCalledWith('api-key', undefined); + expect(result).toBe('api-value'); + }); + + it('should handle complete CRUD operations', async () => { + (NativeKVProvider.isAvailable as jest.Mock).mockReturnValue(false); + mockAPIProvider.set.mockReset(); + mockAPIProvider.get.mockReset(); + mockAPIProvider.delete.mockReset(); + + mockAPIProvider.set.mockResolvedValue(undefined); + mockAPIProvider.get.mockResolvedValue('stored-value'); + mockAPIProvider.delete.mockResolvedValue(undefined); + + const client = await createClient({ namespace: 'test' }).connect(); + + await client.set('key1', 'stored-value'); + expect(mockAPIProvider.set).toHaveBeenCalledWith('key1', 'stored-value', undefined); + + const value = await client.get('key1'); + expect(value).toBe('stored-value'); + + await client.delete('key1'); + expect(mockAPIProvider.delete).toHaveBeenCalledWith('key1'); + }); + }); +}); diff --git a/packages/kv/__tests__/providers/native.test.ts b/packages/kv/__tests__/providers/native.test.ts new file mode 100644 index 00000000..7300031a --- /dev/null +++ b/packages/kv/__tests__/providers/native.test.ts @@ -0,0 +1,413 @@ +import { KVError } from '../../src/errors'; +import { NativeKVProvider } from '../../src/providers/native'; +import type { AzionKVNamespace } from '../../src/providers/types'; + +describe('NativeKVProvider', () => { + let mockKVNamespace: Omit; + let mockAzionKV: AzionKVNamespace; + + beforeEach(() => { + mockKVNamespace = { + get: jest.fn(), + getWithMetadata: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }; + + mockAzionKV = { + open: jest.fn().mockResolvedValue(mockKVNamespace), + get: jest.fn(), + getWithMetadata: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).Azion = { + KV: mockAzionKV, + }; + }); + + afterEach(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).Azion; + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create provider with namespace', async () => { + const provider = await NativeKVProvider.create('test-namespace'); + + expect(mockAzionKV.open).toHaveBeenCalledWith('test-namespace'); + expect(provider).toBeInstanceOf(NativeKVProvider); + }); + + it('should throw error when Azion.KV is not available', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).Azion; + + await expect(NativeKVProvider.create('test-namespace')).rejects.toThrow(KVError); + await expect(NativeKVProvider.create('test-namespace')).rejects.toThrow( + 'Azion.KV is not available in globalThis', + ); + }); + }); + + describe('isAvailable', () => { + it('should return true when Azion.KV is available', () => { + expect(NativeKVProvider.isAvailable()).toBe(true); + }); + + it('should return false when Azion is not defined', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).Azion; + + expect(NativeKVProvider.isAvailable()).toBe(false); + }); + + it('should return false when Azion.KV is not defined', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).Azion = {}; + + expect(NativeKVProvider.isAvailable()).toBe(false); + }); + }); + + describe('get', () => { + let provider: NativeKVProvider; + + beforeEach(async () => { + provider = await NativeKVProvider.create('test-namespace'); + }); + + it('should get value with default text type', async () => { + const mockValue = 'test-value'; + (mockKVNamespace.get as jest.Mock).mockResolvedValue(mockValue); + + const result = await provider.get('test-key'); + + expect(mockKVNamespace.get).toHaveBeenCalledWith('test-key', 'text', { + cacheTtl: undefined, + }); + expect(result).toBe(mockValue); + }); + + it('should get value with specified type', async () => { + const mockValue = { data: 'test' }; + (mockKVNamespace.get as jest.Mock).mockResolvedValue(mockValue); + + const result = await provider.get('test-key', { type: 'json' }); + + expect(mockKVNamespace.get).toHaveBeenCalledWith('test-key', 'json', { + cacheTtl: undefined, + }); + expect(result).toEqual(mockValue); + }); + + it('should get value with cacheTtl option', async () => { + const mockValue = 'test-value'; + (mockKVNamespace.get as jest.Mock).mockResolvedValue(mockValue); + + const result = await provider.get('test-key', { cacheTtl: 3600 }); + + expect(mockKVNamespace.get).toHaveBeenCalledWith('test-key', 'text', { + cacheTtl: 3600, + }); + expect(result).toBe(mockValue); + }); + + it('should return null when key does not exist', async () => { + (mockKVNamespace.get as jest.Mock).mockResolvedValue(null); + + const result = await provider.get('non-existent-key'); + + expect(result).toBeNull(); + }); + + it('should get ArrayBuffer type', async () => { + const mockBuffer = new ArrayBuffer(8); + (mockKVNamespace.get as jest.Mock).mockResolvedValue(mockBuffer); + + const result = await provider.get('test-key', { type: 'arrayBuffer' }); + + expect(mockKVNamespace.get).toHaveBeenCalledWith('test-key', 'arrayBuffer', { + cacheTtl: undefined, + }); + expect(result).toBe(mockBuffer); + }); + }); + + describe('getWithMetadata', () => { + let provider: NativeKVProvider; + + beforeEach(async () => { + provider = await NativeKVProvider.create('test-namespace'); + }); + + it('should get value with metadata', async () => { + const mockResponse = { + value: 'test-value', + metadata: { userId: '123', created: 1234567890 }, + }; + (mockKVNamespace.getWithMetadata as jest.Mock).mockResolvedValue(mockResponse); + + const result = await provider.getWithMetadata('test-key'); + + expect(mockKVNamespace.getWithMetadata).toHaveBeenCalledWith('test-key', 'text', { + cacheTtl: undefined, + }); + expect(result).toEqual({ + value: 'test-value', + metadata: { userId: '123', created: 1234567890 }, + }); + }); + + it('should get value with metadata and custom type', async () => { + const mockResponse = { + value: { data: 'test' }, + metadata: { version: 1 }, + }; + (mockKVNamespace.getWithMetadata as jest.Mock).mockResolvedValue(mockResponse); + + const result = await provider.getWithMetadata('test-key', { type: 'json' }); + + expect(mockKVNamespace.getWithMetadata).toHaveBeenCalledWith('test-key', 'json', { + cacheTtl: undefined, + }); + expect(result).toEqual({ + value: { data: 'test' }, + metadata: { version: 1 }, + }); + }); + + it('should handle null metadata', async () => { + const mockResponse = { + value: 'test-value', + metadata: null, + }; + (mockKVNamespace.getWithMetadata as jest.Mock).mockResolvedValue(mockResponse); + + const result = await provider.getWithMetadata('test-key'); + + expect(result).toEqual({ + value: 'test-value', + metadata: undefined, + }); + }); + + it('should get with cacheTtl option', async () => { + const mockResponse = { + value: 'test-value', + metadata: { test: true }, + }; + (mockKVNamespace.getWithMetadata as jest.Mock).mockResolvedValue(mockResponse); + + await provider.getWithMetadata('test-key', { cacheTtl: 7200 }); + + expect(mockKVNamespace.getWithMetadata).toHaveBeenCalledWith('test-key', 'text', { + cacheTtl: 7200, + }); + }); + }); + + describe('set', () => { + let provider: NativeKVProvider; + + beforeEach(async () => { + provider = await NativeKVProvider.create('test-namespace'); + }); + + it('should set string value', async () => { + (mockKVNamespace.put as jest.Mock).mockResolvedValue(undefined); + + await provider.set('test-key', 'test-value'); + + expect(mockKVNamespace.put).toHaveBeenCalledWith('test-key', 'test-value', { + expiration: undefined, + expirationTtl: undefined, + metadata: undefined, + }); + }); + + it('should set value with expiration EX (seconds from now)', async () => { + (mockKVNamespace.put as jest.Mock).mockResolvedValue(undefined); + + await provider.set('test-key', 'test-value', { + expiration: { + type: 'EX', + value: 3600, + }, + }); + + expect(mockKVNamespace.put).toHaveBeenCalledWith('test-key', 'test-value', { + expiration: undefined, + expirationTtl: 3600, + metadata: undefined, + }); + }); + + it('should set value with expiration PX (milliseconds from now)', async () => { + (mockKVNamespace.put as jest.Mock).mockResolvedValue(undefined); + + await provider.set('test-key', 'test-value', { + expiration: { + type: 'PX', + value: 5000, // 5000ms = 5 seconds + }, + }); + + expect(mockKVNamespace.put).toHaveBeenCalledWith('test-key', 'test-value', { + expiration: undefined, + expirationTtl: 5, // converted to seconds + metadata: undefined, + }); + }); + + it('should set value with expiration EXAT (Unix timestamp in seconds)', async () => { + (mockKVNamespace.put as jest.Mock).mockResolvedValue(undefined); + + await provider.set('test-key', 'test-value', { + expiration: { + type: 'EXAT', + value: 1234567890, + }, + }); + + expect(mockKVNamespace.put).toHaveBeenCalledWith('test-key', 'test-value', { + expiration: 1234567890, + expirationTtl: undefined, + metadata: undefined, + }); + }); + + it('should set value with expiration PXAT (Unix timestamp in milliseconds)', async () => { + (mockKVNamespace.put as jest.Mock).mockResolvedValue(undefined); + + await provider.set('test-key', 'test-value', { + expiration: { + type: 'PXAT', + value: 1234567890000, // milliseconds + }, + }); + + expect(mockKVNamespace.put).toHaveBeenCalledWith('test-key', 'test-value', { + expiration: 1234567890, // converted to seconds + expirationTtl: undefined, + metadata: undefined, + }); + }); + + it('should set value with expiration KEEPTTL (no expiration set)', async () => { + (mockKVNamespace.put as jest.Mock).mockResolvedValue(undefined); + + await provider.set('test-key', 'test-value', { + expiration: { + type: 'KEEPTTL', + }, + }); + + expect(mockKVNamespace.put).toHaveBeenCalledWith('test-key', 'test-value', { + expiration: undefined, + expirationTtl: undefined, + metadata: undefined, + }); + }); + + it('should set value with expirationTtl', async () => { + (mockKVNamespace.put as jest.Mock).mockResolvedValue(undefined); + + await provider.set('test-key', 'test-value', { + expirationTtl: 3600, + }); + + expect(mockKVNamespace.put).toHaveBeenCalledWith('test-key', 'test-value', { + expiration: undefined, + expirationTtl: 3600, + metadata: undefined, + }); + }); + + it('should set value with metadata', async () => { + (mockKVNamespace.put as jest.Mock).mockResolvedValue(undefined); + + await provider.set('test-key', 'test-value', { + metadata: { userId: '123', version: 1 }, + }); + + expect(mockKVNamespace.put).toHaveBeenCalledWith('test-key', 'test-value', { + expiration: undefined, + expirationTtl: undefined, + metadata: { userId: '123', version: 1 }, + }); + }); + + it('should set value with all options (expirationTtl takes precedence)', async () => { + (mockKVNamespace.put as jest.Mock).mockResolvedValue(undefined); + + await provider.set('test-key', 'test-value', { + expiration: { + type: 'EX', + value: 1800, + }, + expirationTtl: 3600, + metadata: { test: true }, + }); + + expect(mockKVNamespace.put).toHaveBeenCalledWith('test-key', 'test-value', { + expiration: undefined, + expirationTtl: 3600, + metadata: { test: true }, + }); + }); + + it('should set ArrayBuffer value', async () => { + const buffer = new ArrayBuffer(8); + (mockKVNamespace.put as jest.Mock).mockResolvedValue(undefined); + + await provider.set('test-key', buffer); + + expect(mockKVNamespace.put).toHaveBeenCalledWith('test-key', buffer, { + expiration: undefined, + expirationTtl: undefined, + metadata: undefined, + }); + }); + + it('should set ReadableStream value', async () => { + const stream = new ReadableStream(); + (mockKVNamespace.put as jest.Mock).mockResolvedValue(undefined); + + await provider.set('test-key', stream); + + expect(mockKVNamespace.put).toHaveBeenCalledWith('test-key', stream, { + expiration: undefined, + expirationTtl: undefined, + metadata: undefined, + }); + }); + }); + + describe('delete', () => { + let provider: NativeKVProvider; + + beforeEach(async () => { + provider = await NativeKVProvider.create('test-namespace'); + }); + + it('should delete key', async () => { + (mockKVNamespace.delete as jest.Mock).mockResolvedValue(undefined); + + await provider.delete('test-key'); + + expect(mockKVNamespace.delete).toHaveBeenCalledWith('test-key'); + }); + + it('should handle deleting non-existent key', async () => { + (mockKVNamespace.delete as jest.Mock).mockResolvedValue(undefined); + + await expect(provider.delete('non-existent-key')).resolves.not.toThrow(); + + expect(mockKVNamespace.delete).toHaveBeenCalledWith('non-existent-key'); + }); + }); +}); diff --git a/packages/kv/jest.config.ts b/packages/kv/jest.config.ts new file mode 100644 index 00000000..e0d28516 --- /dev/null +++ b/packages/kv/jest.config.ts @@ -0,0 +1,10 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + displayName: 'KV', + preset: 'ts-jest', + transform: { + '^.+\\.(t|j)s?$': 'ts-jest', + }, + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + testEnvironment: 'node', +}; diff --git a/packages/kv/package.json b/packages/kv/package.json new file mode 100644 index 00000000..57360c4d --- /dev/null +++ b/packages/kv/package.json @@ -0,0 +1,29 @@ +{ + "name": "@lib/kv", + "version": "1.0.0", + "description": "", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "scripts": { + "compile": "tsup --config ../../tsup.config.json", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "prettier": "prettier --write .", + "test": "jest --clearCache && jest -c jest.config.ts .", + "test:watch": "jest -c jest.config.js . --watch", + "test:coverage": "jest --clearCache && jest -c jest.config.js . --coverage" + }, + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs" + } + }, + "author": "aziontech", + "license": "MIT", + "files": [ + "dist", + "package.json" + ] +} diff --git a/packages/kv/src/client.ts b/packages/kv/src/client.ts new file mode 100644 index 00000000..02feb7f4 --- /dev/null +++ b/packages/kv/src/client.ts @@ -0,0 +1,171 @@ +import { APIKVProvider } from './providers/api'; +import { NativeKVProvider } from './providers/native'; +import type { KVProvider } from './providers/types'; +import type { KVClientOptions, KVGetOptions, KVGetResult, KVGetValue, KVSetOptions, KVValue } from './types'; + +type EventHandler = (error: Error) => void; + +export class KVClient { + private provider?: KVProvider; + private options: KVClientOptions; + private eventHandlers: Map = new Map(); + private isConnected = false; + + private constructor(options: KVClientOptions) { + this.options = options; + } + + static create(options?: KVClientOptions): KVClient { + return new KVClient(options || {}); + } + + on(event: 'error', handler: EventHandler): this { + if (!this.eventHandlers.has(event)) { + this.eventHandlers.set(event, []); + } + this.eventHandlers.get(event)!.push(handler); + return this; + } + + private emit(event: string, error: Error): void { + const handlers = this.eventHandlers.get(event); + if (handlers) { + handlers.forEach((handler) => handler(error)); + } + } + + async connect(): Promise { + if (this.isConnected) { + return this; + } + + try { + const providerType = this.options.provider || 'auto'; + const namespace = this.options.namespace || process.env.AZION_KV_NAMESPACE; + + if (!namespace) { + throw new Error('namespace is required'); + } + + if (providerType === 'native' || (providerType === 'auto' && NativeKVProvider.isAvailable())) { + this.provider = await NativeKVProvider.create(namespace); + } else { + this.provider = new APIKVProvider({ + apiToken: this.options.apiToken, + namespace, + environment: this.options.environment, + }); + } + + this.isConnected = true; + return this; + } catch (error) { + this.emit('error', error as Error); + throw error; + } + } + + private ensureConnected(): void { + if (!this.isConnected || !this.provider) { + throw new Error('Client is not connected. Call .connect() first.'); + } + } + + async get(key: string, options?: KVGetOptions): Promise { + this.ensureConnected(); + return this.provider!.get(key, options); + } + + async getWithMetadata(key: string, options?: KVGetOptions): Promise { + this.ensureConnected(); + return this.provider!.getWithMetadata(key, options); + } + + async set(key: string, value: KVValue, options?: KVSetOptions): Promise { + this.ensureConnected(); + return this.provider!.set(key, value, options); + } + + async delete(key: string): Promise { + this.ensureConnected(); + return this.provider!.delete(key); + } + + getProviderType(): 'native' | 'api' { + this.ensureConnected(); + return this.provider! instanceof NativeKVProvider ? 'native' : 'api'; + } + + async disconnect(): Promise { + this.isConnected = false; + this.provider = undefined; + return Promise.resolve(); + } + + async quit(): Promise { + return this.disconnect(); + } + + async hSet(key: string, field: string, value: KVValue): Promise { + this.ensureConnected(); + const existing = await this.provider!.get(key); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let hash: Record = {}; + + if (existing) { + try { + hash = JSON.parse(existing as string); + } catch { + hash = {}; + } + } + + hash[field] = value; + return this.provider!.set(key, JSON.stringify(hash)); + } + + async HSET(key: string, field: string, value: KVValue): Promise { + return this.hSet(key, field, value); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async hGetAll(key: string): Promise | null> { + this.ensureConnected(); + const existing = await this.provider!.get(key); + + if (!existing) { + return null; + } + + try { + return JSON.parse(existing as string); + } catch { + return null; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async HGETALL(key: string): Promise | null> { + return this.hGetAll(key); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async hVals(key: string): Promise { + const hash = await this.hGetAll(key); + + if (!hash) { + return null; + } + + return Object.values(hash); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async HVALS(key: string): Promise { + return this.hVals(key); + } +} + +export function createClient(options?: KVClientOptions): KVClient { + return KVClient.create(options); +} diff --git a/packages/kv/src/errors.ts b/packages/kv/src/errors.ts new file mode 100644 index 00000000..6dafe2db --- /dev/null +++ b/packages/kv/src/errors.ts @@ -0,0 +1,27 @@ +export class KVError extends Error { + constructor(message: string) { + super(message); + this.name = 'KVError'; + } +} + +export class KVConnectionError extends KVError { + constructor(message: string) { + super(message); + this.name = 'KVConnectionError'; + } +} + +export class KVTimeoutError extends KVError { + constructor(message: string) { + super(message); + this.name = 'KVTimeoutError'; + } +} + +export class KVNotFoundError extends KVError { + constructor(key: string) { + super(`Key not found: ${key}`); + this.name = 'KVNotFoundError'; + } +} diff --git a/packages/kv/src/index.ts b/packages/kv/src/index.ts new file mode 100644 index 00000000..67c6983d --- /dev/null +++ b/packages/kv/src/index.ts @@ -0,0 +1,3 @@ +export { createClient, type KVClient } from './client'; +export * from './errors'; +export * from './types'; diff --git a/packages/kv/src/providers/api.ts b/packages/kv/src/providers/api.ts new file mode 100644 index 00000000..50195d13 --- /dev/null +++ b/packages/kv/src/providers/api.ts @@ -0,0 +1,121 @@ +import { KVError } from '../errors'; +import type { KVGetOptions, KVGetResult, KVSetOptions, KVValue } from '../types'; +import type { KVProvider } from './types'; + +export interface APIKVProviderOptions { + apiToken?: string; + namespace?: string; + environment?: 'production' | 'stage'; +} + +export class APIKVProvider implements KVProvider { + private apiUrl: string; + private apiToken?: string; + private namespace?: string; + + constructor(options?: APIKVProviderOptions) { + if (options?.environment === 'production') { + this.apiUrl = 'https://api.azion.com/v4/workspace/kv'; + } else { + this.apiUrl = 'https://stage-api.azion.com/v4/workspace/kv'; + } + this.apiToken = options?.apiToken || process.env.AZION_API_TOKEN; + this.namespace = options?.namespace; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async get(key: string, options?: KVGetOptions): Promise { + // const url = this.buildUrl(`/keys/${encodeURIComponent(key)}`); + + try { + throw new KVError('Not implemented'); + } catch (error) { + if (error instanceof KVError) throw error; + throw new KVError(`Failed to get key: ${error}`); + } + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getWithMetadata(key: string, options?: KVGetOptions): Promise { + // const url = this.buildUrl(`/keys/${encodeURIComponent(key)}`); + + try { + throw new KVError('Not implemented'); + } catch (error) { + if (error instanceof KVError) throw error; + throw new KVError(`Failed to get key with metadata: ${error}`); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async set(key: string, value: KVValue, options?: KVSetOptions): Promise { + // const url = this.buildUrl(`/keys/${encodeURIComponent(key)}`); + + // let body: string; + // if (value instanceof ArrayBuffer) { + // body = JSON.stringify({ value: this.arrayBufferToBase64(value) }); + // } else if (value instanceof ReadableStream) { + // throw new KVError('ReadableStream not supported in API provider yet'); + // } else { + // body = JSON.stringify({ + // value, + // expirationTtl: options?.expirationTtl, + // metadata: options?.metadata, + // }); + // } + + try { + // const response = await fetch(url, { + // method: 'PUT', + // headers: this.buildHeaders(), + // body, + // }); + + // if (!response.ok) { + // throw new KVError(`Failed to set key: ${response.statusText}`); + // } + throw new KVError('Not implemented'); + } catch (error) { + if (error instanceof KVError) throw error; + throw new KVError(`Failed to set key: ${error}`); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async delete(key: string): Promise { + // const url = this.buildUrl(`/keys/${encodeURIComponent(key)}`); + + try { + throw new KVError('Not implemented'); + } catch (error) { + if (error instanceof KVError) throw error; + throw new KVError(`Failed to delete key: ${error}`); + } + } + + private buildUrl(path: string): string { + const base = this.apiUrl.endsWith('/') ? this.apiUrl.slice(0, -1) : this.apiUrl; + const namespace = this.namespace ? `/namespaces/${this.namespace}` : ''; + return `${base}${namespace}${path}`; + } + + private buildHeaders(): HeadersInit { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + if (this.apiToken) { + headers['Authorization'] = `Bearer ${this.apiToken}`; + } + + return headers; + } + + private arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } +} diff --git a/packages/kv/src/providers/index.ts b/packages/kv/src/providers/index.ts new file mode 100644 index 00000000..3d4357d9 --- /dev/null +++ b/packages/kv/src/providers/index.ts @@ -0,0 +1,3 @@ +export * from './api'; +export * from './native'; +export * from './types'; diff --git a/packages/kv/src/providers/native.ts b/packages/kv/src/providers/native.ts new file mode 100644 index 00000000..19200684 --- /dev/null +++ b/packages/kv/src/providers/native.ts @@ -0,0 +1,94 @@ +import { KVError } from '../errors'; +import type { KVGetOptions, KVGetResult, KVGetValue, KVSetOptions, KVValue } from '../types'; +import type { AzionKVNamespace, KVProvider } from './types'; + +export class NativeKVProvider implements KVProvider { + private kv: Omit; + + private constructor(kv: Omit) { + this.kv = kv; + } + + static async create(namespace: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(globalThis as any).Azion?.KV) { + throw new KVError('Azion.KV is not available in globalThis'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const kv = await (globalThis as any).Azion.KV.open(namespace); + return new NativeKVProvider(kv); + } + + async get(key: string, options?: KVGetOptions): Promise { + const type = options?.type || 'text'; + const result = await this.kv.get(key, type, { cacheTtl: options?.cacheTtl }); + return result as KVGetValue | null; + } + + async getWithMetadata(key: string, options?: KVGetOptions): Promise { + const type = options?.type || 'text'; + const result = await this.kv.getWithMetadata(key, type, { cacheTtl: options?.cacheTtl }); + + return { + value: result.value as KVGetValue | null, + metadata: result.metadata || undefined, + }; + } + + async set(key: string, value: KVValue, options?: KVSetOptions): Promise { + let expiration: number | undefined; + let expirationTtl: number | undefined; + + if (options?.expiration) { + const { type, value } = options.expiration; + + switch (type) { + case 'EX': + // EX: seconds from now -> use expirationTtl + expirationTtl = value; + break; + case 'PX': + // PX: milliseconds from now -> convert to seconds for expirationTtl + expirationTtl = value ? Math.ceil(value / 1000) : undefined; + break; + case 'EXAT': + // EXAT: Unix timestamp in seconds -> use expiration directly + expiration = value; + break; + case 'PXAT': + // PXAT: Unix timestamp in milliseconds -> convert to seconds for expiration + expiration = value ? Math.floor(value / 1000) : undefined; + break; + case 'KEEPTTL': + // KEEPTTL: don't set any expiration (keep existing TTL) + break; + } + } + + // If expirationTtl was provided directly in options, use it (backwards compatibility) + if (options?.expirationTtl !== undefined) { + expirationTtl = options.expirationTtl; + } + + await this.kv.put(key, value, { + expiration, + expirationTtl, + metadata: options?.metadata, + }); + } + + async delete(key: string): Promise { + await this.kv.delete(key); + } + + static isAvailable(): boolean { + return ( + typeof globalThis !== 'undefined' && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).Azion !== undefined && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).Azion.KV !== undefined + ); + } +} diff --git a/packages/kv/src/providers/types.ts b/packages/kv/src/providers/types.ts new file mode 100644 index 00000000..d7783ee7 --- /dev/null +++ b/packages/kv/src/providers/types.ts @@ -0,0 +1,46 @@ +import type { KVGetOptions, KVGetResult, KVGetValue, KVSetOptions, KVValue } from '../types'; + +export interface KVProvider { + get(key: string, options?: KVGetOptions): Promise; + getWithMetadata(key: string, options?: KVGetOptions): Promise; + set(key: string, value: KVValue, options?: KVSetOptions): Promise; + delete(key: string): Promise; +} + +export interface AzionKVNamespace { + open(namespace: string): Promise>; + get( + key: string, + type?: 'text' | 'json' | 'arrayBuffer' | 'stream', + options?: { cacheTtl?: number }, + ): Promise>; + getWithMetadata( + key: string, + type?: 'text' | 'json' | 'arrayBuffer' | 'stream', + options?: { cacheTtl?: number }, + ): Promise<{ + value: string | ArrayBuffer | ReadableStream | Record; + metadata: Record | null; + }>; + put( + key: string, + value: string | ArrayBuffer | ReadableStream, + options?: { + expiration?: number; + expirationTtl?: number; + metadata?: Record; + }, + ): Promise; + delete(key: string): Promise; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace globalThis { + const Azion: + | { + KV?: AzionKVNamespace; + } + | undefined; + } +} diff --git a/packages/kv/src/types.ts b/packages/kv/src/types.ts new file mode 100644 index 00000000..6b111769 --- /dev/null +++ b/packages/kv/src/types.ts @@ -0,0 +1,35 @@ +export interface KVClientOptions { + namespace?: string; + timeout?: number; + provider?: 'auto' | 'native' | 'api'; + apiToken?: string; + environment?: 'production' | 'stage'; +} + +export interface KVGetOptions { + metadata?: boolean; + cacheTtl?: number; + type?: 'text' | 'json' | 'arrayBuffer' | 'stream'; +} +//EX seconds -- Set the specified expire time, in seconds (a positive integer). +//PX milliseconds -- Set the specified expire time, in milliseconds (a positive integer). +//EXAT timestamp-seconds -- Set the specified Unix time at which the key will expire, in seconds (a positive integer). +//PXAT timestamp-milliseconds -- Set the specified Unix time at which the key will expire, in milliseconds (a positive integer). +//KEEPTTL -- Retain the time to live associated with the key. +export interface KVSetOptions { + expiration?: { + type: 'EX' | 'PX' | 'EXAT' | 'PXAT' | 'KEEPTTL'; + value?: number; + }; + expirationTtl?: number; + metadata?: Record; +} + +export type KVGetValue = string | ArrayBuffer | ReadableStream | Record; + +export interface KVGetResult { + value: KVGetValue | null; + metadata?: Record; +} + +export type KVValue = string | ArrayBuffer | ReadableStream; diff --git a/packages/kv/tsconfig.json b/packages/kv/tsconfig.json new file mode 100644 index 00000000..69b56c19 --- /dev/null +++ b/packages/kv/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "ESNext", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +}