From dfa5eb73fa53c914f51f25cfe55ddf15da9f57bc Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 2 Mar 2026 00:43:06 -0800 Subject: [PATCH] =?UTF-8?q?feat(packages):=20add=20cloud-agnostic=20abstra?= =?UTF-8?q?ction=20packages=20=E2=80=94=20datastore,=20storage,=20llm,=20p?= =?UTF-8?q?ush=20+=20refactor=20secrets=20(58=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/config/src/index.ts | 2 + packages/config/src/keyvault.ts | 79 +++-- packages/datastore/package.json | 35 +++ .../datastore/src/__tests__/memory.test.ts | 278 ++++++++++++++++++ packages/datastore/src/factory.ts | 59 ++++ packages/datastore/src/filter.ts | 178 +++++++++++ packages/datastore/src/index.ts | 18 ++ packages/datastore/src/providers/cosmos.ts | 243 +++++++++++++++ packages/datastore/src/providers/memory.ts | 202 +++++++++++++ packages/datastore/src/testing.ts | 53 ++++ packages/datastore/src/types.ts | 113 +++++++ packages/datastore/tsconfig.json | 9 + packages/llm/package.json | 27 ++ packages/llm/src/__tests__/llm.test.ts | 76 +++++ packages/llm/src/factory.ts | 73 +++++ packages/llm/src/index.ts | 13 + packages/llm/src/providers/azure-openai.ts | 88 ++++++ packages/llm/src/providers/mock.ts | 48 +++ packages/llm/src/providers/openai.ts | 81 +++++ packages/llm/src/testing.ts | 18 ++ packages/llm/src/types.ts | 48 +++ packages/llm/tsconfig.json | 9 + packages/push/package.json | 27 ++ packages/push/src/__tests__/push.test.ts | 47 +++ packages/push/src/factory.ts | 41 +++ packages/push/src/index.ts | 5 + packages/push/src/providers/expo.ts | 64 ++++ packages/push/src/providers/mock.ts | 28 ++ packages/push/src/testing.ts | 18 ++ packages/push/src/types.ts | 36 +++ packages/push/tsconfig.json | 9 + packages/storage/package.json | 35 +++ .../src/__tests__/memory-storage.test.ts | 80 +++++ packages/storage/src/factory.ts | 52 ++++ packages/storage/src/index.ts | 12 + packages/storage/src/providers/azure-blob.ts | 170 +++++++++++ packages/storage/src/providers/memory.ts | 83 ++++++ packages/storage/src/testing.ts | 24 ++ packages/storage/src/types.ts | 59 ++++ packages/storage/tsconfig.json | 9 + pnpm-lock.yaml | 36 ++- 41 files changed, 2566 insertions(+), 19 deletions(-) create mode 100644 packages/datastore/package.json create mode 100644 packages/datastore/src/__tests__/memory.test.ts create mode 100644 packages/datastore/src/factory.ts create mode 100644 packages/datastore/src/filter.ts create mode 100644 packages/datastore/src/index.ts create mode 100644 packages/datastore/src/providers/cosmos.ts create mode 100644 packages/datastore/src/providers/memory.ts create mode 100644 packages/datastore/src/testing.ts create mode 100644 packages/datastore/src/types.ts create mode 100644 packages/datastore/tsconfig.json create mode 100644 packages/llm/package.json create mode 100644 packages/llm/src/__tests__/llm.test.ts create mode 100644 packages/llm/src/factory.ts create mode 100644 packages/llm/src/index.ts create mode 100644 packages/llm/src/providers/azure-openai.ts create mode 100644 packages/llm/src/providers/mock.ts create mode 100644 packages/llm/src/providers/openai.ts create mode 100644 packages/llm/src/testing.ts create mode 100644 packages/llm/src/types.ts create mode 100644 packages/llm/tsconfig.json create mode 100644 packages/push/package.json create mode 100644 packages/push/src/__tests__/push.test.ts create mode 100644 packages/push/src/factory.ts create mode 100644 packages/push/src/index.ts create mode 100644 packages/push/src/providers/expo.ts create mode 100644 packages/push/src/providers/mock.ts create mode 100644 packages/push/src/testing.ts create mode 100644 packages/push/src/types.ts create mode 100644 packages/push/tsconfig.json create mode 100644 packages/storage/package.json create mode 100644 packages/storage/src/__tests__/memory-storage.test.ts create mode 100644 packages/storage/src/factory.ts create mode 100644 packages/storage/src/index.ts create mode 100644 packages/storage/src/providers/azure-blob.ts create mode 100644 packages/storage/src/providers/memory.ts create mode 100644 packages/storage/src/testing.ts create mode 100644 packages/storage/src/types.ts create mode 100644 packages/storage/tsconfig.json diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 216d2520..8768b6ce 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -7,7 +7,9 @@ export { type ProductIdentity, } from './product-identity.js'; export { + resolveSecrets, resolveKeyVaultSecrets, LYSNR_SECRETS, type SecretMapping, + type SecretsProviderType, } from './keyvault.js'; diff --git a/packages/config/src/keyvault.ts b/packages/config/src/keyvault.ts index 3cbe0ee8..7b592295 100644 --- a/packages/config/src/keyvault.ts +++ b/packages/config/src/keyvault.ts @@ -1,39 +1,84 @@ /** - * Azure Key Vault secret resolution for Node.js services + dashboards. + * Cloud-agnostic secret resolution for Node.js services + dashboards. * - * Call resolveKeyVaultSecrets() BEFORE Zod config parsing to populate - * process.env from Key Vault. Falls back gracefully when AKV is unavailable. + * Call resolveSecrets() BEFORE Zod config parsing to populate + * process.env from a secrets provider. Falls back gracefully when unavailable. * - * Requires: AZURE_KEYVAULT_URL env var (skip if not set). - * Auth: DefaultAzureCredential (managed identity in prod, az cli locally). + * Provider selection via SECRETS_PROVIDER env var: + * - 'azure-keyvault' (default if AZURE_KEYVAULT_URL is set) — Azure Key Vault + * - 'env' (default if no vault URL) — do nothing, use .env values as-is + * + * Backward compatible: resolveKeyVaultSecrets() still works identically. */ +export type SecretsProviderType = 'azure-keyvault' | 'env'; + export interface SecretMapping { - /** Azure Key Vault secret name (e.g. 'lysnr-cosmos-key') */ + /** Provider-specific secret name (e.g. 'lysnr-cosmos-key' for AKV) */ kvName: string; /** Environment variable name to populate (e.g. 'COSMOS_KEY') */ envVar: string; } /** - * Resolve secrets from Azure Key Vault into process.env. + * Resolve which secrets provider to use. + */ +function resolveSecretsProvider(): SecretsProviderType { + const explicit = (process.env.SECRETS_PROVIDER || '').toLowerCase(); + if (explicit === 'azure-keyvault' || explicit === 'azure') return 'azure-keyvault'; + if (explicit === 'env') return 'env'; + + // Auto-detect: use AKV if AZURE_KEYVAULT_URL is set + if (process.env.AZURE_KEYVAULT_URL) return 'azure-keyvault'; + return 'env'; +} + +/** + * Cloud-agnostic secret resolution into process.env. * * - Only fetches secrets whose env var is empty/unset (env takes precedence). - * - Skips entirely if AZURE_KEYVAULT_URL is not set. + * - Skips entirely if provider is 'env' or no vault is configured. * - Logs warnings but does NOT throw — services fall back to .env values. * * @param secrets - Array of {kvName, envVar} mappings - * @param opts - Optional: custom vault URL override + * @param opts - Optional overrides + */ +export async function resolveSecrets( + secrets: SecretMapping[], + opts?: { vaultUrl?: string; provider?: SecretsProviderType } +): Promise { + const provider = opts?.provider ?? resolveSecretsProvider(); + + if (provider === 'env') return; // Nothing to resolve — use env vars as-is + + if (provider === 'azure-keyvault') { + return resolveAzureKeyVaultSecrets(secrets, opts); + } +} + +/** + * Resolve secrets from Azure Key Vault into process.env. + * @deprecated Use resolveSecrets() instead — this is kept for backward compatibility. */ export async function resolveKeyVaultSecrets( secrets: SecretMapping[], - opts?: { vaultUrl?: string }, + opts?: { vaultUrl?: string } +): Promise { + return resolveAzureKeyVaultSecrets(secrets, opts); +} + +/** + * Azure Key Vault implementation. + */ +async function resolveAzureKeyVaultSecrets( + secrets: SecretMapping[], + opts?: { vaultUrl?: string } ): Promise { const vaultUrl = opts?.vaultUrl || process.env.AZURE_KEYVAULT_URL; if (!vaultUrl) return; // No KV configured — use env vars as-is // Filter to only secrets that are missing from env - const missing = secrets.filter((s) => !process.env[s.envVar]); + const missing = secrets.filter(s => !process.env[s.envVar]); if (missing.length === 0) return; // All secrets already in env try { @@ -43,22 +88,22 @@ export async function resolveKeyVaultSecrets( const client = new SecretClient(vaultUrl, new DefaultAzureCredential()); const results = await Promise.allSettled( - missing.map(async (s) => { + missing.map(async s => { const secret = await client.getSecret(s.kvName); if (secret.value) { process.env[s.envVar] = secret.value; } - }), + }) ); - const failures = results.filter((r) => r.status === 'rejected'); + const failures = results.filter(r => r.status === 'rejected'); if (failures.length > 0) { console.warn( - `[keyvault] ${failures.length}/${missing.length} secrets failed to resolve — falling back to env vars`, + `[secrets] ${failures.length}/${missing.length} secrets failed to resolve — falling back to env vars` ); } - } catch (err) { - console.warn(`[keyvault] Unable to connect to Key Vault at ${vaultUrl} — using env vars`); + } catch { + console.warn(`[secrets] Unable to connect to Key Vault at ${vaultUrl} — using env vars`); } } diff --git a/packages/datastore/package.json b/packages/datastore/package.json new file mode 100644 index 00000000..637fb508 --- /dev/null +++ b/packages/datastore/package.json @@ -0,0 +1,35 @@ +{ + "name": "@bytelyst/datastore", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "peerDependencies": { + "@azure/cosmos": ">=4.0.0" + }, + "peerDependenciesMeta": { + "@azure/cosmos": { + "optional": true + } + }, + "devDependencies": { + "vitest": "^3.0.0" + } +} diff --git a/packages/datastore/src/__tests__/memory.test.ts b/packages/datastore/src/__tests__/memory.test.ts new file mode 100644 index 00000000..3fe979e2 --- /dev/null +++ b/packages/datastore/src/__tests__/memory.test.ts @@ -0,0 +1,278 @@ +/** + * Tests for MemoryDatastoreProvider and filter evaluation. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { MemoryDatastoreProvider } from '../providers/memory.js'; +import { matchesFilter, filterToCosmosSQL } from '../filter.js'; +import type { BaseDocument, DocumentCollection } from '../types.js'; + +interface TestDoc extends BaseDocument { + name: string; + price?: number; + tags?: string[]; + createdAt?: string; +} + +describe('MemoryDatastoreProvider', () => { + let provider: MemoryDatastoreProvider; + let collection: DocumentCollection; + + beforeEach(() => { + provider = new MemoryDatastoreProvider(); + collection = provider.getCollection('test', '/productId'); + }); + + it('isHealthy returns true', async () => { + expect(await provider.isHealthy()).toBe(true); + }); + + it('returns same collection instance for same name', () => { + const c1 = provider.getCollection('test', '/productId'); + const c2 = provider.getCollection('test', '/productId'); + expect(c1).toBe(c2); + }); + + describe('CRUD operations', () => { + const doc: TestDoc = { id: '1', productId: 'test', name: 'Widget', price: 10 }; + + it('create + findById', async () => { + const created = await collection.create(doc); + expect(created).toEqual(doc); + const found = await collection.findById('1', 'test'); + expect(found).toEqual(doc); + }); + + it('create throws on duplicate', async () => { + await collection.create(doc); + await expect(collection.create(doc)).rejects.toThrow('already exists'); + }); + + it('findById returns null for missing', async () => { + expect(await collection.findById('missing', 'test')).toBeNull(); + }); + + it('update merges fields', async () => { + await collection.create(doc); + const updated = await collection.update('1', 'test', { price: 20 }); + expect(updated.price).toBe(20); + expect(updated.name).toBe('Widget'); + }); + + it('update throws for missing doc', async () => { + await expect(collection.update('missing', 'test', { price: 20 })).rejects.toThrow( + 'not found' + ); + }); + + it('upsert creates or replaces', async () => { + const created = await collection.upsert(doc); + expect(created).toEqual(doc); + const replaced = await collection.upsert({ ...doc, name: 'Gadget' }); + expect(replaced.name).toBe('Gadget'); + }); + + it('delete removes doc', async () => { + await collection.create(doc); + await collection.delete('1', 'test'); + expect(await collection.findById('1', 'test')).toBeNull(); + }); + }); + + describe('findMany', () => { + const docs: TestDoc[] = [ + { id: '1', productId: 'p1', name: 'Alpha', price: 30, createdAt: '2026-01-01' }, + { id: '2', productId: 'p1', name: 'Beta', price: 10, createdAt: '2026-01-02' }, + { id: '3', productId: 'p1', name: 'Gamma', price: 20, createdAt: '2026-01-03' }, + { id: '4', productId: 'p2', name: 'Delta', price: 15, createdAt: '2026-01-04' }, + ]; + + beforeEach(async () => { + for (const d of docs) await collection.create(d); + }); + + it('returns all without query', async () => { + const results = await collection.findMany(); + expect(results).toHaveLength(4); + }); + + it('filters by exact match', async () => { + const results = await collection.findMany({ filter: { productId: 'p1' } }); + expect(results).toHaveLength(3); + }); + + it('filters with $gt', async () => { + const results = await collection.findMany({ filter: { price: { $gt: 15 } } }); + expect(results).toHaveLength(2); + }); + + it('filters with $in', async () => { + const results = await collection.findMany({ filter: { name: { $in: ['Alpha', 'Gamma'] } } }); + expect(results).toHaveLength(2); + }); + + it('sorts ascending', async () => { + const results = await collection.findMany({ sort: { price: 1 } }); + expect(results.map(d => d.price)).toEqual([10, 15, 20, 30]); + }); + + it('sorts descending', async () => { + const results = await collection.findMany({ sort: { price: -1 } }); + expect(results.map(d => d.price)).toEqual([30, 20, 15, 10]); + }); + + it('limits results', async () => { + const results = await collection.findMany({ limit: 2 }); + expect(results).toHaveLength(2); + }); + + it('offsets + limits', async () => { + const all = await collection.findMany({ sort: { price: 1 } }); + const page2 = await collection.findMany({ sort: { price: 1 }, offset: 2, limit: 2 }); + expect(page2).toEqual(all.slice(2, 4)); + }); + + it('selects specific fields', async () => { + const results = await collection.findMany({ select: ['id', 'name'] }); + expect(results[0]).toHaveProperty('id'); + expect(results[0]).toHaveProperty('name'); + expect(results[0]).not.toHaveProperty('price'); + }); + }); + + describe('findOne', () => { + it('returns first match', async () => { + await collection.create({ id: '1', productId: 'p', name: 'A' }); + await collection.create({ id: '2', productId: 'p', name: 'B' }); + const result = await collection.findOne({ filter: { productId: 'p' } }); + expect(result).not.toBeNull(); + }); + + it('returns null when no match', async () => { + const result = await collection.findOne({ filter: { productId: 'missing' } }); + expect(result).toBeNull(); + }); + }); + + describe('count', () => { + it('counts all without filter', async () => { + await collection.create({ id: '1', productId: 'p', name: 'A' }); + await collection.create({ id: '2', productId: 'p', name: 'B' }); + expect(await collection.count()).toBe(2); + }); + + it('counts with filter', async () => { + await collection.create({ id: '1', productId: 'p1', name: 'A' }); + await collection.create({ id: '2', productId: 'p2', name: 'B' }); + expect(await collection.count({ productId: 'p1' })).toBe(1); + }); + }); + + describe('aggregate', () => { + it('groups and aggregates', async () => { + await collection.create({ id: '1', productId: 'p1', name: 'A', price: 10 }); + await collection.create({ id: '2', productId: 'p1', name: 'B', price: 20 }); + await collection.create({ id: '3', productId: 'p2', name: 'C', price: 30 }); + + const results = await collection.aggregate<{ productId: string; total: number; cnt: number }>( + { + groupBy: 'productId', + aggregations: [ + { field: 'price', op: 'sum', alias: 'total' }, + { field: 'price', op: 'count', alias: 'cnt' }, + ], + } + ); + + expect(results).toHaveLength(2); + const p1 = results.find(r => r.productId === 'p1'); + expect(p1?.total).toBe(30); + expect(p1?.cnt).toBe(2); + }); + }); + + describe('rawQuery', () => { + it('throws for memory provider', async () => { + await expect(collection.rawQuery('SELECT * FROM c')).rejects.toThrow('not supported'); + }); + }); +}); + +describe('matchesFilter', () => { + const doc = { id: '1', productId: 'p', name: 'test', price: 25, tags: ['a', 'b'] }; + + it('exact match', () => { + expect(matchesFilter(doc, { name: 'test' })).toBe(true); + expect(matchesFilter(doc, { name: 'other' })).toBe(false); + }); + + it('$ne', () => { + expect(matchesFilter(doc, { price: { $ne: 30 } })).toBe(true); + expect(matchesFilter(doc, { price: { $ne: 25 } })).toBe(false); + }); + + it('$exists', () => { + expect(matchesFilter(doc, { price: { $exists: true } })).toBe(true); + expect(matchesFilter(doc, { missing: { $exists: false } })).toBe(true); + expect(matchesFilter(doc, { price: { $exists: false } })).toBe(false); + }); + + it('$startsWith', () => { + expect(matchesFilter(doc, { name: { $startsWith: 'te' } })).toBe(true); + expect(matchesFilter(doc, { name: { $startsWith: 'no' } })).toBe(false); + }); + + it('$contains on string', () => { + expect(matchesFilter(doc, { name: { $contains: 'es' } })).toBe(true); + }); + + it('$contains on array', () => { + expect(matchesFilter(doc, { tags: { $contains: 'a' } })).toBe(true); + expect(matchesFilter(doc, { tags: { $contains: 'z' } })).toBe(false); + }); + + it('$or', () => { + expect(matchesFilter(doc, { $or: [{ name: 'test' }, { name: 'other' }] })).toBe(true); + expect(matchesFilter(doc, { $or: [{ name: 'x' }, { name: 'y' }] })).toBe(false); + }); + + it('combined conditions', () => { + expect(matchesFilter(doc, { productId: 'p', price: { $gte: 20, $lt: 30 } })).toBe(true); + expect(matchesFilter(doc, { productId: 'p', price: { $gte: 30 } })).toBe(false); + }); +}); + +describe('filterToCosmosSQL', () => { + it('exact match', () => { + const result = filterToCosmosSQL({ name: 'test' }); + expect(result.query).toBe('WHERE c.name = @p0'); + expect(result.parameters).toEqual([{ name: '@p0', value: 'test' }]); + }); + + it('comparison operators', () => { + const result = filterToCosmosSQL({ price: { $gte: 10, $lt: 50 } }); + expect(result.query).toContain('c.price >= @p0'); + expect(result.query).toContain('c.price < @p1'); + }); + + it('$exists', () => { + const result = filterToCosmosSQL({ deleted: { $exists: false } }); + expect(result.query).toContain('NOT IS_DEFINED(c.deleted)'); + }); + + it('$in', () => { + const result = filterToCosmosSQL({ status: { $in: ['active', 'pending'] } }); + expect(result.query).toContain('c.status IN'); + expect(result.parameters).toHaveLength(2); + }); + + it('$or', () => { + const result = filterToCosmosSQL({ $or: [{ a: 1 }, { b: 2 }] }); + expect(result.query).toContain('OR'); + }); + + it('empty filter returns empty WHERE', () => { + const result = filterToCosmosSQL({}); + expect(result.query).toBe(''); + }); +}); diff --git a/packages/datastore/src/factory.ts b/packages/datastore/src/factory.ts new file mode 100644 index 00000000..3aa0ff6c --- /dev/null +++ b/packages/datastore/src/factory.ts @@ -0,0 +1,59 @@ +/** + * Datastore provider factory. + * + * Creates a DatastoreProvider based on DB_PROVIDER env var or explicit type. + * Defaults to 'cosmos' for backward compatibility. + */ + +import { MemoryDatastoreProvider } from './providers/memory.js'; +import type { DatastoreProvider, DatastoreProviderType } from './types.js'; + +let _provider: DatastoreProvider | null = null; + +/** + * Get the singleton datastore provider. + * Lazily creates on first call based on DB_PROVIDER env var. + * + * - 'cosmos' (default) — Azure Cosmos DB + * - 'memory' — In-memory (for testing) + */ +export async function getDatastore(): Promise { + if (!_provider) { + const providerType = (process.env.DB_PROVIDER || 'cosmos') as DatastoreProviderType; + _provider = await createDatastoreProvider(providerType); + } + return _provider; +} + +/** + * Create a datastore provider by type. + */ +export async function createDatastoreProvider( + type: DatastoreProviderType +): Promise { + switch (type) { + case 'cosmos': { + const { CosmosDatastoreProvider } = await import('./providers/cosmos.js'); + return new CosmosDatastoreProvider(); + } + case 'memory': + return new MemoryDatastoreProvider(); + default: + throw new Error(`Unknown DB_PROVIDER: '${type}'. Valid: cosmos, memory`); + } +} + +/** + * Set the singleton datastore provider directly (for testing or manual wiring). + */ +export function setDatastore(provider: DatastoreProvider): void { + _provider = provider; +} + +/** + * Reset the singleton (for testing). + * @internal + */ +export function _resetDatastore(): void { + _provider = null; +} diff --git a/packages/datastore/src/filter.ts b/packages/datastore/src/filter.ts new file mode 100644 index 00000000..7f37282c --- /dev/null +++ b/packages/datastore/src/filter.ts @@ -0,0 +1,178 @@ +/** + * FilterMap evaluation utilities. + * + * Used by memory provider for in-memory filtering and + * by cosmos provider for SQL query generation. + */ + +import type { FilterMap, FilterOperator, FilterValue } from './types.js'; + +/** + * Evaluate a FilterMap against a document (in-memory). + * Returns true if the document matches all filter conditions. + */ +export function matchesFilter(doc: Record, filter: FilterMap): boolean { + for (const [key, condition] of Object.entries(filter)) { + if (key === '$or') { + const orClauses = condition as FilterMap[]; + if (!orClauses.some(clause => matchesFilter(doc, clause))) return false; + continue; + } + if (condition === undefined) continue; + + const value = getNestedValue(doc, key); + + if (isFilterOperator(condition)) { + if (!matchesOperator(value, condition as FilterOperator)) return false; + } else { + // Exact match + if (value !== condition) return false; + } + } + return true; +} + +function getNestedValue(obj: Record, path: string): unknown { + const parts = path.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[part]; + } + return current; +} + +function isFilterOperator(value: unknown): value is FilterOperator { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const keys = Object.keys(value); + return keys.some(k => k.startsWith('$')); +} + +function matchesOperator(docValue: unknown, op: FilterOperator): boolean { + if (op.$gt !== undefined && !(compare(docValue, op.$gt) > 0)) return false; + if (op.$gte !== undefined && !(compare(docValue, op.$gte) >= 0)) return false; + if (op.$lt !== undefined && !(compare(docValue, op.$lt) < 0)) return false; + if (op.$lte !== undefined && !(compare(docValue, op.$lte) <= 0)) return false; + if (op.$ne !== undefined && docValue === op.$ne) return false; + + if (op.$exists !== undefined) { + const exists = docValue !== undefined && docValue !== null; + if (op.$exists !== exists) return false; + } + + if (op.$startsWith !== undefined) { + if (typeof docValue !== 'string') return false; + if (!docValue.startsWith(op.$startsWith)) return false; + } + + if (op.$contains !== undefined) { + if (Array.isArray(docValue)) { + if (!docValue.includes(op.$contains)) return false; + } else if (typeof docValue === 'string' && typeof op.$contains === 'string') { + if (!docValue.includes(op.$contains)) return false; + } else { + return false; + } + } + + if (op.$in !== undefined) { + if (!op.$in.includes(docValue as FilterValue)) return false; + } + + return true; +} + +function compare(a: unknown, b: unknown): number { + if (a === b) return 0; + if (a == null) return -1; + if (b == null) return 1; + if (typeof a === 'number' && typeof b === 'number') return a - b; + if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); + if (typeof a === 'boolean' && typeof b === 'boolean') return (a ? 1 : 0) - (b ? 1 : 0); + return String(a).localeCompare(String(b)); +} + +// ── Cosmos SQL generation ─────────────────────────────────────────────────── + +export interface SqlQuery { + query: string; + parameters: Array<{ name: string; value: unknown }>; +} + +/** + * Convert a FilterMap to a Cosmos SQL WHERE clause. + */ +export function filterToCosmosSQL(filter: FilterMap): SqlQuery { + const parameters: Array<{ name: string; value: unknown }> = []; + let paramIndex = 0; + + function nextParam(value: unknown): string { + const name = `@p${paramIndex++}`; + parameters.push({ name, value }); + return name; + } + + function buildCondition(key: string, condition: unknown): string { + if (condition === null) { + return `c.${key} = null`; + } + + if (!isFilterOperator(condition)) { + return `c.${key} = ${nextParam(condition)}`; + } + + const op = condition as FilterOperator; + const parts: string[] = []; + + if (op.$gt !== undefined) parts.push(`c.${key} > ${nextParam(op.$gt)}`); + if (op.$gte !== undefined) parts.push(`c.${key} >= ${nextParam(op.$gte)}`); + if (op.$lt !== undefined) parts.push(`c.${key} < ${nextParam(op.$lt)}`); + if (op.$lte !== undefined) parts.push(`c.${key} <= ${nextParam(op.$lte)}`); + if (op.$ne !== undefined) parts.push(`c.${key} != ${nextParam(op.$ne)}`); + + if (op.$exists !== undefined) { + parts.push(op.$exists ? `IS_DEFINED(c.${key})` : `NOT IS_DEFINED(c.${key})`); + } + + if (op.$startsWith !== undefined) { + parts.push(`STARTSWITH(c.${key}, ${nextParam(op.$startsWith)})`); + } + + if (op.$contains !== undefined) { + // Could be array or string — use CONTAINS for string, ARRAY_CONTAINS for array + // In Cosmos SQL, we use ARRAY_CONTAINS for arrays and CONTAINS for strings + // Since we can't know the type at SQL-gen time, use CONTAINS (works for strings) + // Callers dealing with arrays should use ARRAY_CONTAINS via rawQuery + parts.push(`CONTAINS(c.${key}, ${nextParam(op.$contains)})`); + } + + if (op.$in !== undefined) { + const placeholders = op.$in.map(v => nextParam(v)); + parts.push(`c.${key} IN (${placeholders.join(', ')})`); + } + + return parts.join(' AND '); + } + + function buildFilter(f: FilterMap): string { + const clauses: string[] = []; + + for (const [key, condition] of Object.entries(f)) { + if (key === '$or') { + const orClauses = (condition as FilterMap[]).map(c => `(${buildFilter(c)})`); + clauses.push(`(${orClauses.join(' OR ')})`); + continue; + } + if (condition === undefined) continue; + clauses.push(buildCondition(key, condition)); + } + + return clauses.join(' AND '); + } + + const where = buildFilter(filter); + return { + query: where ? `WHERE ${where}` : '', + parameters, + }; +} diff --git a/packages/datastore/src/index.ts b/packages/datastore/src/index.ts new file mode 100644 index 00000000..e0718b84 --- /dev/null +++ b/packages/datastore/src/index.ts @@ -0,0 +1,18 @@ +export type { + BaseDocument, + FilterMap, + FilterValue, + FilterOperator, + FilterCondition, + CollectionQuery, + AggregateQuery, + AggregationField, + DocumentCollection, + DatastoreProvider, + DatastoreProviderType, +} from './types.js'; + +export { getDatastore, createDatastoreProvider, setDatastore, _resetDatastore } from './factory.js'; +export { CosmosDatastoreProvider, type CosmosProviderConfig } from './providers/cosmos.js'; +export { MemoryDatastoreProvider } from './providers/memory.js'; +export { matchesFilter, filterToCosmosSQL } from './filter.js'; diff --git a/packages/datastore/src/providers/cosmos.ts b/packages/datastore/src/providers/cosmos.ts new file mode 100644 index 00000000..059f9d8a --- /dev/null +++ b/packages/datastore/src/providers/cosmos.ts @@ -0,0 +1,243 @@ +/** + * Azure Cosmos DB datastore provider. + * + * Wraps @azure/cosmos SDK behind the cloud-agnostic DocumentCollection interface. + * Translates FilterMap queries to Cosmos SQL. + */ + +import { filterToCosmosSQL } from '../filter.js'; +import type { + AggregateQuery, + BaseDocument, + CollectionQuery, + DatastoreProvider, + DocumentCollection, + FilterMap, +} from '../types.js'; + +export interface CosmosProviderConfig { + endpoint: string; + key: string; + database: string; +} + +export class CosmosDatastoreProvider implements DatastoreProvider { + private client: import('@azure/cosmos').CosmosClient | null = null; + private databaseRef: import('@azure/cosmos').Database | null = null; + private config: CosmosProviderConfig; + private collections = new Map>(); + + constructor(config?: CosmosProviderConfig) { + this.config = config ?? { + endpoint: getEnvOrThrow('COSMOS_ENDPOINT'), + key: getEnvOrThrow('COSMOS_KEY'), + database: process.env.COSMOS_DATABASE || 'lysnrai', + }; + } + + private async getDatabase() { + if (!this.databaseRef) { + const { CosmosClient } = await import('@azure/cosmos'); + this.client = new CosmosClient({ + endpoint: this.config.endpoint, + key: this.config.key, + }); + this.databaseRef = this.client.database(this.config.database); + } + return this.databaseRef as import('@azure/cosmos').Database; + } + + getCollection( + name: string, + partitionKeyPath: string + ): DocumentCollection { + const cacheKey = `${name}:${partitionKeyPath}`; + let collection = this.collections.get(cacheKey); + if (!collection) { + collection = new CosmosCollection(name, partitionKeyPath, () => + this.getDatabase() + ); + this.collections.set(cacheKey, collection); + } + return collection as unknown as DocumentCollection; + } + + async isHealthy(): Promise { + try { + const db = await this.getDatabase(); + await db.read(); + return true; + } catch { + return false; + } + } +} + +class CosmosCollection implements DocumentCollection { + constructor( + private containerName: string, + private partitionKeyPath: string, + private getDatabase: () => Promise + ) {} + + private async container() { + const db = await this.getDatabase(); + return db.container(this.containerName); + } + + private pkField(): string { + // Convert /userId to userId, /id to id, etc. + return this.partitionKeyPath.replace(/^\//, ''); + } + + async findById(id: string, partitionKey: string): Promise { + try { + const c = await this.container(); + const { resource } = await c.item(id, partitionKey).read(); + return resource ?? null; + } catch (err: unknown) { + if ((err as { code?: number })?.code === 404) return null; + throw err; + } + } + + async findMany(query?: CollectionQuery): Promise { + const c = await this.container(); + const sql = this.buildSelectSQL(query); + const { resources } = await c.items + .query({ + query: sql.query, + parameters: sql.parameters as import('@azure/cosmos').SqlParameter[], + }) + .fetchAll(); + return resources; + } + + async findOne(query?: CollectionQuery): Promise { + const results = await this.findMany({ ...query, limit: 1 }); + return results[0] ?? null; + } + + async count(filter?: FilterMap): Promise { + const c = await this.container(); + const whereClause = filter ? filterToCosmosSQL(filter) : { query: '', parameters: [] }; + const { resources } = await c.items + .query({ + query: `SELECT VALUE COUNT(1) FROM c ${whereClause.query}`, + parameters: whereClause.parameters as import('@azure/cosmos').SqlParameter[], + }) + .fetchAll(); + return resources[0] ?? 0; + } + + async create(doc: T): Promise { + const c = await this.container(); + const { resource } = await c.items.create(doc); + return resource as T; + } + + async update(id: string, partitionKey: string, updates: Partial): Promise { + const c = await this.container(); + const { resource: existing } = await c.item(id, partitionKey).read(); + if (!existing) throw new Error(`Document '${id}' not found`); + const merged = { ...existing, ...updates } as T; + const { resource } = await c.item(id, partitionKey).replace(merged); + return resource as T; + } + + async upsert(doc: T): Promise { + const c = await this.container(); + const { resource } = await c.items.upsert(doc); + return resource as T; + } + + async delete(id: string, partitionKey: string): Promise { + const c = await this.container(); + await c.item(id, partitionKey).delete(); + } + + async aggregate>(query: AggregateQuery): Promise { + const c = await this.container(); + const whereClause = query.filter + ? filterToCosmosSQL(query.filter) + : { query: '', parameters: [] }; + + const aggFields = query.aggregations + .map(a => { + const func = a.op === 'count' ? `COUNT(1)` : `${a.op.toUpperCase()}(c.${a.field})`; + return `${func} AS ${a.alias}`; + }) + .join(', '); + + const { resources } = await c.items + .query({ + query: `SELECT c.${query.groupBy}, ${aggFields} FROM c ${whereClause.query} GROUP BY c.${query.groupBy}`, + parameters: whereClause.parameters as import('@azure/cosmos').SqlParameter[], + }) + .fetchAll(); + return resources; + } + + async rawQuery(query: string, parameters?: Record): Promise { + const c = await this.container(); + const cosmosParams = parameters + ? Object.entries(parameters).map(([name, value]) => ({ + name: name.startsWith('@') ? name : `@${name}`, + value, + })) + : []; + const { resources } = await c.items + .query({ + query, + parameters: cosmosParams as import('@azure/cosmos').SqlParameter[], + }) + .fetchAll(); + return resources; + } + + private buildSelectSQL(query?: CollectionQuery): { + query: string; + parameters: Array<{ name: string; value: unknown }>; + } { + const parts: string[] = []; + + // SELECT + if (query?.select && query.select.length > 0) { + const fields = query.select.map(f => `c.${f as string}`).join(', '); + parts.push(`SELECT ${fields} FROM c`); + } else { + parts.push('SELECT * FROM c'); + } + + // WHERE + const whereClause = query?.filter + ? filterToCosmosSQL(query.filter) + : { query: '', parameters: [] }; + const parameters = [...whereClause.parameters]; + if (whereClause.query) parts.push(whereClause.query); + + // ORDER BY + if (query?.sort) { + const orderParts = Object.entries(query.sort) + .map(([field, dir]) => `c.${field} ${dir === 1 ? 'ASC' : 'DESC'}`) + .join(', '); + if (orderParts) parts.push(`ORDER BY ${orderParts}`); + } + + // OFFSET / LIMIT + if (query?.offset !== undefined && query?.limit !== undefined) { + parts.push(`OFFSET ${query.offset} LIMIT ${query.limit}`); + } else if (query?.limit !== undefined) { + parts.push(`OFFSET 0 LIMIT ${query.limit}`); + } + + return { query: parts.join(' '), parameters }; + } +} + +function getEnvOrThrow(name: string): string { + const value = process.env[name]; + if (!value) + throw new Error(`Environment variable ${name} is required for CosmosDatastoreProvider`); + return value; +} diff --git a/packages/datastore/src/providers/memory.ts b/packages/datastore/src/providers/memory.ts new file mode 100644 index 00000000..9d1cc3fa --- /dev/null +++ b/packages/datastore/src/providers/memory.ts @@ -0,0 +1,202 @@ +/** + * In-memory datastore provider — for testing and local dev. + * + * All data is stored in Maps. No persistence. Fast and deterministic. + */ + +import { matchesFilter } from '../filter.js'; +import type { + AggregateQuery, + BaseDocument, + CollectionQuery, + DatastoreProvider, + DocumentCollection, + FilterMap, +} from '../types.js'; + +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)) as T; +} + +export class MemoryDatastoreProvider implements DatastoreProvider { + private collections = new Map>(); + + getCollection( + name: string, + _partitionKeyPath: string + ): DocumentCollection { + let collection = this.collections.get(name); + if (!collection) { + collection = new MemoryCollection(); + this.collections.set(name, collection); + } + return collection as unknown as DocumentCollection; + } + + async isHealthy(): Promise { + return true; + } + + /** Clear all collections (for test cleanup). */ + clear(): void { + this.collections.clear(); + } +} + +class MemoryCollection implements DocumentCollection { + private docs = new Map(); + + async findById(id: string, _partitionKey: string): Promise { + return this.docs.get(id) ?? null; + } + + async findMany(query?: CollectionQuery): Promise { + let results = [...this.docs.values()]; + + if (query?.filter) { + results = results.filter(doc => matchesFilter(doc as Record, query.filter!)); + } + + if (query?.sort) { + const sortEntries = Object.entries(query.sort) as Array<[string, 1 | -1]>; + results.sort((a, b) => { + for (const [field, dir] of sortEntries) { + const aVal = (a as Record)[field]; + const bVal = (b as Record)[field]; + const cmp = compareValues(aVal, bVal); + if (cmp !== 0) return cmp * dir; + } + return 0; + }); + } + + if (query?.offset) { + results = results.slice(query.offset); + } + + if (query?.limit) { + results = results.slice(0, query.limit); + } + + if (query?.select && query.select.length > 0) { + results = results.map(doc => { + const picked: Record = {}; + for (const key of query.select!) { + picked[key as string] = (doc as Record)[key as string]; + } + return picked as T; + }); + } + + return results; + } + + async findOne(query?: CollectionQuery): Promise { + const results = await this.findMany({ ...query, limit: 1 }); + return results[0] ?? null; + } + + async count(filter?: FilterMap): Promise { + if (!filter) return this.docs.size; + const results = [...this.docs.values()].filter(doc => + matchesFilter(doc as Record, filter) + ); + return results.length; + } + + async create(doc: T): Promise { + if (this.docs.has(doc.id)) { + throw new Error(`Document with id '${doc.id}' already exists`); + } + const clone = deepClone(doc); + this.docs.set(doc.id, clone); + return clone; + } + + async update(id: string, _partitionKey: string, updates: Partial): Promise { + const existing = this.docs.get(id); + if (!existing) { + throw new Error(`Document with id '${id}' not found`); + } + const merged = { ...existing, ...updates } as T; + this.docs.set(id, deepClone(merged)); + return deepClone(merged); + } + + async upsert(doc: T): Promise { + const clone = deepClone(doc); + this.docs.set(doc.id, clone); + return clone; + } + + async delete(id: string, _partitionKey: string): Promise { + this.docs.delete(id); + } + + async aggregate>(query: AggregateQuery): Promise { + let docs = [...this.docs.values()]; + if (query.filter) { + docs = docs.filter(doc => matchesFilter(doc as Record, query.filter!)); + } + + // Group by field + const groups = new Map(); + for (const doc of docs) { + const key = String((doc as Record)[query.groupBy] ?? '__null__'); + const group = groups.get(key) ?? []; + group.push(doc); + groups.set(key, group); + } + + const results: Record[] = []; + for (const [groupKey, groupDocs] of groups) { + const row: Record = { [query.groupBy]: groupKey }; + for (const agg of query.aggregations) { + row[agg.alias] = computeAggregation(groupDocs, agg.field, agg.op); + } + results.push(row); + } + + return results as R[]; + } + + async rawQuery(_query: string, _parameters?: Record): Promise { + throw new Error( + 'rawQuery is not supported by MemoryDatastoreProvider. Use findMany/aggregate instead.' + ); + } +} + +function compareValues(a: unknown, b: unknown): number { + if (a === b) return 0; + if (a == null) return -1; + if (b == null) return 1; + if (typeof a === 'number' && typeof b === 'number') return a - b; + if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); + return String(a).localeCompare(String(b)); +} + +function computeAggregation( + docs: T[], + field: string, + op: 'count' | 'sum' | 'avg' | 'min' | 'max' +): number { + if (op === 'count') return docs.length; + + const values = docs + .map(d => (d as Record)[field]) + .filter((v): v is number => typeof v === 'number'); + + if (values.length === 0) return 0; + + switch (op) { + case 'sum': + return values.reduce((a, b) => a + b, 0); + case 'avg': + return values.reduce((a, b) => a + b, 0) / values.length; + case 'min': + return Math.min(...values); + case 'max': + return Math.max(...values); + } +} diff --git a/packages/datastore/src/testing.ts b/packages/datastore/src/testing.ts new file mode 100644 index 00000000..6466bbfd --- /dev/null +++ b/packages/datastore/src/testing.ts @@ -0,0 +1,53 @@ +/** + * Test helpers for @bytelyst/datastore. + * + * Use setTestProvider('memory') in beforeAll() to wire up + * a fast, deterministic in-memory provider for tests. + */ + +import { setDatastore, _resetDatastore } from './factory.js'; +import { MemoryDatastoreProvider } from './providers/memory.js'; +import type { BaseDocument, DocumentCollection, DatastoreProviderType } from './types.js'; + +let _testProvider: MemoryDatastoreProvider | null = null; + +/** + * Set a test provider. Call in beforeAll(). + * Currently only 'memory' is supported for testing. + */ +export function setTestProvider(type: DatastoreProviderType = 'memory'): MemoryDatastoreProvider { + if (type !== 'memory') { + throw new Error(`setTestProvider only supports 'memory', got '${type}'`); + } + _testProvider = new MemoryDatastoreProvider(); + setDatastore(_testProvider); + return _testProvider; +} + +/** + * Clear all test data. Call in afterEach() or afterAll(). + */ +export function clearTestData(): void { + _testProvider?.clear(); +} + +/** + * Reset the test provider. Call in afterAll(). + */ +export function resetTestProvider(): void { + _testProvider?.clear(); + _testProvider = null; + _resetDatastore(); +} + +/** + * Seed a collection with documents for testing. + */ +export async function seedCollection( + collection: DocumentCollection, + docs: T[] +): Promise { + for (const doc of docs) { + await collection.create(doc); + } +} diff --git a/packages/datastore/src/types.ts b/packages/datastore/src/types.ts new file mode 100644 index 00000000..bc9b34b2 --- /dev/null +++ b/packages/datastore/src/types.ts @@ -0,0 +1,113 @@ +/** + * Cloud-agnostic datastore interfaces. + * + * Provides DocumentCollection and DatastoreProvider abstractions + * that work with Cosmos DB, MongoDB, or in-memory storage. + */ + +// ── Base document type ────────────────────────────────────────────────────── + +export interface BaseDocument { + id: string; + productId: string; + [key: string]: unknown; +} + +// ── Filter operators ──────────────────────────────────────────────────────── + +export type FilterValue = string | number | boolean | null; + +export interface FilterOperator { + $gt?: FilterValue; + $gte?: FilterValue; + $lt?: FilterValue; + $lte?: FilterValue; + $ne?: FilterValue; + $exists?: boolean; + $startsWith?: string; + $contains?: FilterValue; + $in?: FilterValue[]; +} + +export type FilterCondition = FilterValue | FilterOperator; + +export interface FilterMap { + [field: string]: FilterCondition | FilterMap[] | undefined; + $or?: FilterMap[]; +} + +// ── Query types ───────────────────────────────────────────────────────────── + +export interface CollectionQuery { + filter?: FilterMap; + sort?: Partial>; + limit?: number; + offset?: number; + select?: (keyof T & string)[]; +} + +export interface AggregateQuery { + groupBy: string; + aggregations: AggregationField[]; + filter?: FilterMap; +} + +export interface AggregationField { + field: string; + op: 'count' | 'sum' | 'avg' | 'min' | 'max'; + alias: string; +} + +// ── Document collection interface ─────────────────────────────────────────── + +export interface DocumentCollection { + /** Find a single document by ID + partition key. */ + findById(id: string, partitionKey: string): Promise; + + /** Find multiple documents matching a query. */ + findMany(query?: CollectionQuery): Promise; + + /** Find the first document matching a query. */ + findOne(query?: CollectionQuery): Promise; + + /** Count documents matching a filter. */ + count(filter?: FilterMap): Promise; + + /** Create a new document. */ + create(doc: T): Promise; + + /** Update a document by ID + partition key (merge semantics). */ + update(id: string, partitionKey: string, updates: Partial): Promise; + + /** Upsert a document (create or replace). */ + upsert(doc: T): Promise; + + /** Delete a document by ID + partition key. */ + delete(id: string, partitionKey: string): Promise; + + /** Run an aggregation query. */ + aggregate>(query: AggregateQuery): Promise; + + /** + * Execute a raw provider-specific query (escape hatch). + * Returns results as-is from the underlying provider. + */ + rawQuery(query: string, parameters?: Record): Promise; +} + +// ── Datastore provider interface ──────────────────────────────────────────── + +export interface DatastoreProvider { + /** Get a collection by name with a partition key path. */ + getCollection( + name: string, + partitionKeyPath: string + ): DocumentCollection; + + /** Check if the datastore is healthy / reachable. */ + isHealthy(): Promise; +} + +// ── Provider config ───────────────────────────────────────────────────────── + +export type DatastoreProviderType = 'cosmos' | 'memory'; diff --git a/packages/datastore/tsconfig.json b/packages/datastore/tsconfig.json new file mode 100644 index 00000000..5edad813 --- /dev/null +++ b/packages/datastore/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/llm/package.json b/packages/llm/package.json new file mode 100644 index 00000000..ae8f148e --- /dev/null +++ b/packages/llm/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bytelyst/llm", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^3.0.0" + } +} diff --git a/packages/llm/src/__tests__/llm.test.ts b/packages/llm/src/__tests__/llm.test.ts new file mode 100644 index 00000000..15656767 --- /dev/null +++ b/packages/llm/src/__tests__/llm.test.ts @@ -0,0 +1,76 @@ +/** + * Tests for LLM providers and factory. + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { MockLLMProvider } from '../providers/mock.js'; +import { createLLMProvider, _resetLLM } from '../factory.js'; + +describe('MockLLMProvider', () => { + let provider: MockLLMProvider; + + beforeEach(() => { + provider = new MockLLMProvider(); + }); + + it('isConfigured returns true', () => { + expect(provider.isConfigured()).toBe(true); + }); + + it('returns default echo response', async () => { + const result = await provider.chatCompletion({ + messages: [{ role: 'user', content: 'Hello' }], + }); + expect(result.content).toContain('Hello'); + expect(result.finishReason).toBe('stop'); + }); + + it('returns queued responses', async () => { + provider.addResponse({ + content: 'Custom response', + model: 'test-model', + finishReason: 'stop', + usage: { promptTokens: 5, completionTokens: 5, totalTokens: 10 }, + }); + + const result = await provider.chatCompletion({ + messages: [{ role: 'user', content: 'Hello' }], + }); + expect(result.content).toBe('Custom response'); + }); + + it('tracks calls', async () => { + const req = { messages: [{ role: 'user' as const, content: 'Test' }] }; + await provider.chatCompletion(req); + expect(provider.calls).toHaveLength(1); + expect(provider.calls[0]).toEqual(req); + }); + + it('reset clears calls and responses', async () => { + provider.addResponse({ + content: 'x', + model: 'm', + finishReason: 'stop', + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }); + await provider.chatCompletion({ messages: [{ role: 'user', content: 'Test' }] }); + provider.reset(); + expect(provider.calls).toHaveLength(0); + }); +}); + +describe('createLLMProvider', () => { + afterEach(() => { + _resetLLM(); + vi.unstubAllEnvs(); + }); + + it('creates mock provider', () => { + const provider = createLLMProvider('mock'); + expect(provider).toBeInstanceOf(MockLLMProvider); + }); + + it('throws for unknown type', () => { + expect(() => createLLMProvider('unknown' as 'mock')).toThrow('Unknown LLM_PROVIDER'); + }); +}); diff --git a/packages/llm/src/factory.ts b/packages/llm/src/factory.ts new file mode 100644 index 00000000..5a60e44c --- /dev/null +++ b/packages/llm/src/factory.ts @@ -0,0 +1,73 @@ +/** + * LLM provider factory. + * + * Creates an LLMProvider based on LLM_PROVIDER env var. + * Auto-detects Azure vs OpenAI from endpoint URLs if not explicitly set. + */ + +import { AzureOpenAIProvider } from './providers/azure-openai.js'; +import { MockLLMProvider } from './providers/mock.js'; +import { OpenAIProvider } from './providers/openai.js'; +import type { LLMProvider, LLMProviderType } from './types.js'; + +let _provider: LLMProvider | null = null; + +/** + * Resolve provider type from env vars. + * Priority: LLM_PROVIDER > OPENAI_PROVIDER > auto-detect from endpoint URLs. + */ +function resolveProviderType(): LLMProviderType { + const explicit = (process.env.LLM_PROVIDER || process.env.OPENAI_PROVIDER || '').toLowerCase(); + if (explicit === 'azure') return 'azure'; + if (explicit === 'openai') return 'openai'; + if (explicit === 'mock') return 'mock'; + + const azureEndpoint = process.env.AZURE_OPENAI_ENDPOINT; + const baseUrl = process.env.OPENAI_BASE_URL || ''; + if (azureEndpoint && azureEndpoint.trim().length > 0) return 'azure'; + if (baseUrl.includes('.cognitive.microsoft.com') || baseUrl.includes('.openai.azure.com')) + return 'azure'; + + return 'openai'; +} + +/** + * Get the singleton LLM provider. + */ +export function getLLM(): LLMProvider { + if (!_provider) { + const type = resolveProviderType(); + _provider = createLLMProvider(type); + } + return _provider; +} + +/** + * Create an LLM provider by type. + */ +export function createLLMProvider(type: LLMProviderType): LLMProvider { + switch (type) { + case 'azure': + return new AzureOpenAIProvider(); + case 'openai': + return new OpenAIProvider(); + case 'mock': + return new MockLLMProvider(); + default: + throw new Error(`Unknown LLM_PROVIDER: '${type}'. Valid: azure, openai, mock`); + } +} + +/** + * Set the singleton LLM provider (for testing). + */ +export function setLLM(provider: LLMProvider): void { + _provider = provider; +} + +/** + * @internal + */ +export function _resetLLM(): void { + _provider = null; +} diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts new file mode 100644 index 00000000..6cf88a8f --- /dev/null +++ b/packages/llm/src/index.ts @@ -0,0 +1,13 @@ +export type { + LLMProvider, + ChatCompletionRequest, + ChatCompletionResponse, + ChatMessage, + TokenUsage, + LLMProviderType, +} from './types.js'; + +export { getLLM, createLLMProvider, setLLM, _resetLLM } from './factory.js'; +export { AzureOpenAIProvider, type AzureOpenAIConfig } from './providers/azure-openai.js'; +export { OpenAIProvider, type OpenAIConfig } from './providers/openai.js'; +export { MockLLMProvider } from './providers/mock.js'; diff --git a/packages/llm/src/providers/azure-openai.ts b/packages/llm/src/providers/azure-openai.ts new file mode 100644 index 00000000..bdafbae6 --- /dev/null +++ b/packages/llm/src/providers/azure-openai.ts @@ -0,0 +1,88 @@ +/** + * Azure OpenAI LLM provider. + * + * Uses Azure OpenAI REST API with api-key authentication. + * Reads config from AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_KEY, AZURE_OPENAI_DEPLOYMENT. + */ + +import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from '../types.js'; + +export interface AzureOpenAIConfig { + endpoint: string; + apiKey: string; + deployment: string; + apiVersion?: string; +} + +export class AzureOpenAIProvider implements LLMProvider { + private config: AzureOpenAIConfig; + + constructor(config?: Partial) { + this.config = { + endpoint: config?.endpoint || process.env.AZURE_OPENAI_ENDPOINT || '', + apiKey: config?.apiKey || process.env.AZURE_OPENAI_KEY || process.env.OPENAI_API_KEY || '', + deployment: + config?.deployment || + process.env.AZURE_OPENAI_DEPLOYMENT || + process.env.OPENAI_MODEL || + 'gpt-4o-mini', + apiVersion: config?.apiVersion || process.env.AZURE_OPENAI_API_VERSION || '2024-06-01', + }; + } + + isConfigured(): boolean { + return Boolean(this.config.endpoint && this.config.apiKey && this.config.deployment); + } + + async chatCompletion(req: ChatCompletionRequest): Promise { + if (!this.isConfigured()) { + throw new Error( + 'Azure OpenAI is not configured (missing AZURE_OPENAI_ENDPOINT or AZURE_OPENAI_KEY)' + ); + } + + const base = this.config.endpoint.replace(/\/+$/, ''); + const url = `${base}/openai/deployments/${encodeURIComponent(this.config.deployment)}/chat/completions?api-version=${encodeURIComponent(this.config.apiVersion!)}`; + + const body = { + messages: req.messages, + temperature: req.temperature, + max_tokens: req.maxTokens, + top_p: req.topP, + stop: req.stop, + response_format: req.responseFormat, + }; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'api-key': this.config.apiKey, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Azure OpenAI error ${response.status}: ${text}`); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string }; finish_reason: string }>; + model: string; + usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; + }; + + return { + content: data.choices[0]?.message?.content ?? '', + model: data.model, + finishReason: + (data.choices[0]?.finish_reason as ChatCompletionResponse['finishReason']) ?? null, + usage: { + promptTokens: data.usage.prompt_tokens, + completionTokens: data.usage.completion_tokens, + totalTokens: data.usage.total_tokens, + }, + }; + } +} diff --git a/packages/llm/src/providers/mock.ts b/packages/llm/src/providers/mock.ts new file mode 100644 index 00000000..f3141233 --- /dev/null +++ b/packages/llm/src/providers/mock.ts @@ -0,0 +1,48 @@ +/** + * Mock LLM provider — for testing. + * + * Returns pre-configured responses or a default echo response. + */ + +import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from '../types.js'; + +export class MockLLMProvider implements LLMProvider { + private responses: ChatCompletionResponse[] = []; + public calls: ChatCompletionRequest[] = []; + + constructor(responses?: ChatCompletionResponse[]) { + if (responses) this.responses = [...responses]; + } + + isConfigured(): boolean { + return true; + } + + /** Add a response to the queue. */ + addResponse(response: ChatCompletionResponse): void { + this.responses.push(response); + } + + async chatCompletion(req: ChatCompletionRequest): Promise { + this.calls.push(req); + + if (this.responses.length > 0) { + return this.responses.shift()!; + } + + // Default echo response + const lastMessage = req.messages[req.messages.length - 1]; + return { + content: `Mock response to: ${lastMessage?.content ?? '(empty)'}`, + model: req.model ?? 'mock-model', + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 }, + }; + } + + /** Reset call history and responses. */ + reset(): void { + this.calls = []; + this.responses = []; + } +} diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts new file mode 100644 index 00000000..e2267139 --- /dev/null +++ b/packages/llm/src/providers/openai.ts @@ -0,0 +1,81 @@ +/** + * OpenAI direct LLM provider. + * + * Uses OpenAI REST API with Bearer token authentication. + * Reads config from OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL. + */ + +import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from '../types.js'; + +export interface OpenAIConfig { + apiKey: string; + baseUrl?: string; + model?: string; +} + +export class OpenAIProvider implements LLMProvider { + private config: OpenAIConfig; + + constructor(config?: Partial) { + this.config = { + apiKey: config?.apiKey || process.env.OPENAI_API_KEY || '', + baseUrl: config?.baseUrl || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', + model: config?.model || process.env.OPENAI_MODEL || 'gpt-4o-mini', + }; + } + + isConfigured(): boolean { + return Boolean(this.config.apiKey); + } + + async chatCompletion(req: ChatCompletionRequest): Promise { + if (!this.isConfigured()) { + throw new Error('OpenAI is not configured (missing OPENAI_API_KEY)'); + } + + const base = this.config.baseUrl!.replace(/\/+$/, ''); + const url = `${base}/chat/completions`; + + const body = { + model: req.model || this.config.model, + messages: req.messages, + temperature: req.temperature, + max_tokens: req.maxTokens, + top_p: req.topP, + stop: req.stop, + response_format: req.responseFormat, + }; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`OpenAI error ${response.status}: ${text}`); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string }; finish_reason: string }>; + model: string; + usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; + }; + + return { + content: data.choices[0]?.message?.content ?? '', + model: data.model, + finishReason: + (data.choices[0]?.finish_reason as ChatCompletionResponse['finishReason']) ?? null, + usage: { + promptTokens: data.usage.prompt_tokens, + completionTokens: data.usage.completion_tokens, + totalTokens: data.usage.total_tokens, + }, + }; + } +} diff --git a/packages/llm/src/testing.ts b/packages/llm/src/testing.ts new file mode 100644 index 00000000..18d2c6ce --- /dev/null +++ b/packages/llm/src/testing.ts @@ -0,0 +1,18 @@ +/** + * Test helpers for @bytelyst/llm. + */ + +import { setLLM, _resetLLM } from './factory.js'; +import { MockLLMProvider } from './providers/mock.js'; + +export function setTestLLMProvider(): MockLLMProvider { + const provider = new MockLLMProvider(); + setLLM(provider); + return provider; +} + +export function resetTestLLM(): void { + _resetLLM(); +} + +export { MockLLMProvider } from './providers/mock.js'; diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts new file mode 100644 index 00000000..ad5ad1e6 --- /dev/null +++ b/packages/llm/src/types.ts @@ -0,0 +1,48 @@ +/** + * Cloud-agnostic LLM provider interfaces. + * + * Provides a unified chat completion API that works with + * Azure OpenAI, OpenAI direct, or mock providers. + */ + +export interface LLMProvider { + /** Send a chat completion request. */ + chatCompletion(req: ChatCompletionRequest): Promise; + + /** Stream a chat completion response. */ + chatCompletionStream?(req: ChatCompletionRequest): AsyncIterable; + + /** Check if the provider is configured with valid credentials. */ + isConfigured(): boolean; +} + +export interface ChatCompletionRequest { + messages: ChatMessage[]; + model?: string; + temperature?: number; + maxTokens?: number; + topP?: number; + stop?: string[]; + responseFormat?: { type: 'text' | 'json_object' }; +} + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string; + name?: string; +} + +export interface ChatCompletionResponse { + content: string; + model: string; + usage: TokenUsage; + finishReason: 'stop' | 'length' | 'content_filter' | 'tool_calls' | null; +} + +export interface TokenUsage { + promptTokens: number; + completionTokens: number; + totalTokens: number; +} + +export type LLMProviderType = 'azure' | 'openai' | 'mock'; diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json new file mode 100644 index 00000000..5edad813 --- /dev/null +++ b/packages/llm/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/push/package.json b/packages/push/package.json new file mode 100644 index 00000000..c701b5a5 --- /dev/null +++ b/packages/push/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bytelyst/push", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^3.0.0" + } +} diff --git a/packages/push/src/__tests__/push.test.ts b/packages/push/src/__tests__/push.test.ts new file mode 100644 index 00000000..a543e0bb --- /dev/null +++ b/packages/push/src/__tests__/push.test.ts @@ -0,0 +1,47 @@ +/** + * Tests for push notification providers. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { MockPushProvider } from '../providers/mock.js'; + +describe('MockPushProvider', () => { + let provider: MockPushProvider; + + beforeEach(() => { + provider = new MockPushProvider(); + }); + + it('isConfigured returns true', () => { + expect(provider.isConfigured()).toBe(true); + }); + + it('send records notification', async () => { + const result = await provider.send({ + deviceToken: 'token-123', + platform: 'ios', + title: 'Test', + body: 'Hello', + }); + expect(result.success).toBe(true); + expect(result.messageId).toBeDefined(); + expect(provider.sent).toHaveLength(1); + expect(provider.sent[0]!.title).toBe('Test'); + }); + + it('sendBatch records all notifications', async () => { + const results = await provider.sendBatch([ + { deviceToken: 't1', platform: 'ios', title: 'A', body: 'a' }, + { deviceToken: 't2', platform: 'android', title: 'B', body: 'b' }, + ]); + expect(results).toHaveLength(2); + expect(results.every(r => r.success)).toBe(true); + expect(provider.sent).toHaveLength(2); + }); + + it('reset clears sent history', async () => { + await provider.send({ deviceToken: 't', platform: 'web', title: 'X', body: 'x' }); + provider.reset(); + expect(provider.sent).toHaveLength(0); + }); +}); diff --git a/packages/push/src/factory.ts b/packages/push/src/factory.ts new file mode 100644 index 00000000..b3693fb6 --- /dev/null +++ b/packages/push/src/factory.ts @@ -0,0 +1,41 @@ +/** + * Push notification provider factory. + * + * Creates a PushProvider based on PUSH_PROVIDER env var. + * Defaults to 'mock' since no push infra is wired yet. + */ + +import { ExpoPushProvider } from './providers/expo.js'; +import { MockPushProvider } from './providers/mock.js'; +import type { PushProvider, PushProviderType } from './types.js'; + +let _provider: PushProvider | null = null; + +export function getPush(): PushProvider { + if (!_provider) { + const type = (process.env.PUSH_PROVIDER || 'mock') as PushProviderType; + _provider = createPushProvider(type); + } + return _provider; +} + +export function createPushProvider(type: PushProviderType): PushProvider { + switch (type) { + case 'expo': + return new ExpoPushProvider(); + case 'firebase': + throw new Error('Firebase push provider not yet implemented. Use expo or mock.'); + case 'mock': + return new MockPushProvider(); + default: + throw new Error(`Unknown PUSH_PROVIDER: '${type}'. Valid: expo, firebase, mock`); + } +} + +export function setPush(provider: PushProvider): void { + _provider = provider; +} + +export function _resetPush(): void { + _provider = null; +} diff --git a/packages/push/src/index.ts b/packages/push/src/index.ts new file mode 100644 index 00000000..b25caf64 --- /dev/null +++ b/packages/push/src/index.ts @@ -0,0 +1,5 @@ +export type { PushProvider, PushNotification, PushResult, PushProviderType } from './types.js'; + +export { getPush, createPushProvider, setPush, _resetPush } from './factory.js'; +export { MockPushProvider } from './providers/mock.js'; +export { ExpoPushProvider } from './providers/expo.js'; diff --git a/packages/push/src/providers/expo.ts b/packages/push/src/providers/expo.ts new file mode 100644 index 00000000..df27795a --- /dev/null +++ b/packages/push/src/providers/expo.ts @@ -0,0 +1,64 @@ +/** + * Expo push notification provider. + * + * Uses the Expo push notification service REST API. + * No SDK dependency — just HTTP requests. + */ + +import type { PushNotification, PushProvider, PushResult } from '../types.js'; + +const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'; + +export class ExpoPushProvider implements PushProvider { + isConfigured(): boolean { + return true; // Expo push is open — no API key required for basic use + } + + async send(notification: PushNotification): Promise { + const results = await this.sendBatch([notification]); + return results[0]!; + } + + async sendBatch(notifications: PushNotification[]): Promise { + const messages = notifications.map(n => ({ + to: n.deviceToken, + title: n.title, + body: n.body, + data: n.data, + badge: n.badge, + sound: n.sound ?? 'default', + channelId: n.channelId, + })); + + try { + const response = await fetch(EXPO_PUSH_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(messages), + }); + + if (!response.ok) { + const text = await response.text(); + return notifications.map(() => ({ + success: false, + error: `Expo push error ${response.status}: ${text}`, + })); + } + + const data = (await response.json()) as { + data: Array<{ status: string; id?: string; message?: string }>; + }; + + return data.data.map(ticket => ({ + success: ticket.status === 'ok', + messageId: ticket.id, + error: ticket.status !== 'ok' ? ticket.message : undefined, + })); + } catch (err) { + return notifications.map(() => ({ + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + })); + } + } +} diff --git a/packages/push/src/providers/mock.ts b/packages/push/src/providers/mock.ts new file mode 100644 index 00000000..5e639fd0 --- /dev/null +++ b/packages/push/src/providers/mock.ts @@ -0,0 +1,28 @@ +/** + * Mock push notification provider — for testing and local dev. + * + * Logs notifications instead of sending them. + */ + +import type { PushNotification, PushProvider, PushResult } from '../types.js'; + +export class MockPushProvider implements PushProvider { + public sent: PushNotification[] = []; + + isConfigured(): boolean { + return true; + } + + async send(notification: PushNotification): Promise { + this.sent.push(notification); + return { success: true, messageId: `mock-${Date.now()}-${this.sent.length}` }; + } + + async sendBatch(notifications: PushNotification[]): Promise { + return Promise.all(notifications.map(n => this.send(n))); + } + + reset(): void { + this.sent = []; + } +} diff --git a/packages/push/src/testing.ts b/packages/push/src/testing.ts new file mode 100644 index 00000000..5461036f --- /dev/null +++ b/packages/push/src/testing.ts @@ -0,0 +1,18 @@ +/** + * Test helpers for @bytelyst/push. + */ + +import { setPush, _resetPush } from './factory.js'; +import { MockPushProvider } from './providers/mock.js'; + +export function setTestPushProvider(): MockPushProvider { + const provider = new MockPushProvider(); + setPush(provider); + return provider; +} + +export function resetTestPush(): void { + _resetPush(); +} + +export { MockPushProvider } from './providers/mock.js'; diff --git a/packages/push/src/types.ts b/packages/push/src/types.ts new file mode 100644 index 00000000..37790285 --- /dev/null +++ b/packages/push/src/types.ts @@ -0,0 +1,36 @@ +/** + * Cloud-agnostic push notification interfaces. + * + * Provides a unified push API that works with + * Firebase, APNS, Expo, or mock/log providers. + */ + +export interface PushProvider { + /** Send a single push notification. */ + send(notification: PushNotification): Promise; + + /** Send a batch of push notifications. */ + sendBatch(notifications: PushNotification[]): Promise; + + /** Check if the push provider is configured. */ + isConfigured(): boolean; +} + +export interface PushNotification { + deviceToken: string; + platform: 'ios' | 'android' | 'web'; + title: string; + body: string; + data?: Record; + badge?: number; + sound?: string; + channelId?: string; +} + +export interface PushResult { + success: boolean; + messageId?: string; + error?: string; +} + +export type PushProviderType = 'firebase' | 'expo' | 'mock'; diff --git a/packages/push/tsconfig.json b/packages/push/tsconfig.json new file mode 100644 index 00000000..5edad813 --- /dev/null +++ b/packages/push/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/storage/package.json b/packages/storage/package.json new file mode 100644 index 00000000..fd04dd11 --- /dev/null +++ b/packages/storage/package.json @@ -0,0 +1,35 @@ +{ + "name": "@bytelyst/storage", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "peerDependencies": { + "@azure/storage-blob": ">=12.0.0" + }, + "peerDependenciesMeta": { + "@azure/storage-blob": { + "optional": true + } + }, + "devDependencies": { + "vitest": "^3.0.0" + } +} diff --git a/packages/storage/src/__tests__/memory-storage.test.ts b/packages/storage/src/__tests__/memory-storage.test.ts new file mode 100644 index 00000000..56f0c457 --- /dev/null +++ b/packages/storage/src/__tests__/memory-storage.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for MemoryStorageProvider. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { MemoryStorageProvider } from '../providers/memory.js'; +import type { StorageBucket } from '../types.js'; + +describe('MemoryStorageProvider', () => { + let provider: MemoryStorageProvider; + let bucket: StorageBucket; + + beforeEach(() => { + provider = new MemoryStorageProvider(); + bucket = provider.getBucket('test-bucket'); + }); + + it('isHealthy returns true', async () => { + expect(await provider.isHealthy()).toBe(true); + }); + + it('returns same bucket instance', () => { + const b1 = provider.getBucket('x'); + const b2 = provider.getBucket('x'); + expect(b1).toBe(b2); + }); + + describe('bucket operations', () => { + it('upload + download', async () => { + await bucket.upload('file.txt', 'hello world', { contentType: 'text/plain' }); + const data = await bucket.download('file.txt'); + expect(data.toString()).toBe('hello world'); + }); + + it('upload returns metadata', async () => { + const meta = await bucket.upload('file.txt', Buffer.from('test'), { + contentType: 'text/plain', + metadata: { foo: 'bar' }, + }); + expect(meta.key).toBe('file.txt'); + expect(meta.size).toBe(4); + expect(meta.contentType).toBe('text/plain'); + expect(meta.metadata?.foo).toBe('bar'); + }); + + it('download throws for missing blob', async () => { + await expect(bucket.download('missing')).rejects.toThrow('not found'); + }); + + it('exists returns true/false', async () => { + expect(await bucket.exists('file.txt')).toBe(false); + await bucket.upload('file.txt', 'data'); + expect(await bucket.exists('file.txt')).toBe(true); + }); + + it('delete removes blob', async () => { + await bucket.upload('file.txt', 'data'); + await bucket.delete('file.txt'); + expect(await bucket.exists('file.txt')).toBe(false); + }); + + it('list returns all blobs', async () => { + await bucket.upload('a/1.txt', 'data'); + await bucket.upload('a/2.txt', 'data'); + await bucket.upload('b/1.txt', 'data'); + + const all = await bucket.list(); + expect(all).toHaveLength(3); + + const prefixed = await bucket.list('a/'); + expect(prefixed).toHaveLength(2); + }); + + it('getSignedUrl returns memory URL', async () => { + const url = await bucket.getSignedUrl('file.txt'); + expect(url).toContain('memory://'); + expect(url).toContain('file.txt'); + }); + }); +}); diff --git a/packages/storage/src/factory.ts b/packages/storage/src/factory.ts new file mode 100644 index 00000000..c37c2e42 --- /dev/null +++ b/packages/storage/src/factory.ts @@ -0,0 +1,52 @@ +/** + * Storage provider factory. + * + * Creates a StorageProvider based on STORAGE_PROVIDER env var or explicit type. + * Defaults to 'azure' for backward compatibility. + */ + +import { MemoryStorageProvider } from './providers/memory.js'; +import type { StorageProvider, StorageProviderType } from './types.js'; + +let _provider: StorageProvider | null = null; + +/** + * Get the singleton storage provider. + */ +export async function getStorage(): Promise { + if (!_provider) { + const providerType = (process.env.STORAGE_PROVIDER || 'azure') as StorageProviderType; + _provider = await createStorageProvider(providerType); + } + return _provider; +} + +/** + * Create a storage provider by type. + */ +export async function createStorageProvider(type: StorageProviderType): Promise { + switch (type) { + case 'azure': { + const { AzureBlobStorageProvider } = await import('./providers/azure-blob.js'); + return new AzureBlobStorageProvider(); + } + case 'memory': + return new MemoryStorageProvider(); + default: + throw new Error(`Unknown STORAGE_PROVIDER: '${type}'. Valid: azure, memory`); + } +} + +/** + * Set the singleton storage provider (for testing). + */ +export function setStorage(provider: StorageProvider): void { + _provider = provider; +} + +/** + * @internal + */ +export function _resetStorage(): void { + _provider = null; +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts new file mode 100644 index 00000000..e7040734 --- /dev/null +++ b/packages/storage/src/index.ts @@ -0,0 +1,12 @@ +export type { + StorageProvider, + StorageBucket, + UploadOptions, + SignedUrlOptions, + BlobMeta, + StorageProviderType, +} from './types.js'; + +export { getStorage, createStorageProvider, setStorage, _resetStorage } from './factory.js'; +export { AzureBlobStorageProvider, type AzureBlobProviderConfig } from './providers/azure-blob.js'; +export { MemoryStorageProvider } from './providers/memory.js'; diff --git a/packages/storage/src/providers/azure-blob.ts b/packages/storage/src/providers/azure-blob.ts new file mode 100644 index 00000000..78e72995 --- /dev/null +++ b/packages/storage/src/providers/azure-blob.ts @@ -0,0 +1,170 @@ +/** + * Azure Blob Storage provider. + * + * Wraps @azure/storage-blob behind the cloud-agnostic StorageProvider interface. + */ + +import type { + BlobMeta, + SignedUrlOptions, + StorageBucket, + StorageProvider, + UploadOptions, +} from '../types.js'; + +export interface AzureBlobProviderConfig { + connectionString?: string; + accountName?: string; + accountKey?: string; +} + +export class AzureBlobStorageProvider implements StorageProvider { + private client: unknown = null; + private config: AzureBlobProviderConfig; + private buckets = new Map(); + + constructor(config?: AzureBlobProviderConfig) { + this.config = config ?? { + connectionString: process.env.AZURE_BLOB_CONNECTION_STRING, + accountName: process.env.AZURE_BLOB_ACCOUNT_NAME, + accountKey: process.env.AZURE_BLOB_ACCOUNT_KEY, + }; + } + + private async getClient() { + if (!this.client) { + const { BlobServiceClient } = await import('@azure/storage-blob'); + if (this.config.connectionString) { + this.client = BlobServiceClient.fromConnectionString(this.config.connectionString); + } else if (this.config.accountName && this.config.accountKey) { + const { StorageSharedKeyCredential } = await import('@azure/storage-blob'); + const cred = new StorageSharedKeyCredential( + this.config.accountName, + this.config.accountKey + ); + this.client = new BlobServiceClient( + `https://${this.config.accountName}.blob.core.windows.net`, + cred + ); + } else { + throw new Error( + 'AzureBlobStorageProvider requires AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY' + ); + } + } + return this.client as import('@azure/storage-blob').BlobServiceClient; + } + + getBucket(name: string): StorageBucket { + let bucket = this.buckets.get(name); + if (!bucket) { + bucket = new AzureBlobBucket(name, () => this.getClient(), this.config); + this.buckets.set(name, bucket); + } + return bucket; + } + + async isHealthy(): Promise { + try { + const client = await this.getClient(); + // List one container to verify connectivity + const iter = client.listContainers(); + await iter.next(); + return true; + } catch { + return false; + } + } +} + +class AzureBlobBucket implements StorageBucket { + constructor( + private containerName: string, + private getClient: () => Promise, + private config: AzureBlobProviderConfig + ) {} + + private async containerClient() { + const client = await this.getClient(); + return client.getContainerClient(this.containerName); + } + + async upload( + key: string, + data: Buffer | Uint8Array | string, + options?: UploadOptions + ): Promise { + const container = await this.containerClient(); + const blockBlob = container.getBlockBlobClient(key); + const buf = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data); + await blockBlob.upload(buf, buf.length, { + blobHTTPHeaders: { blobContentType: options?.contentType }, + metadata: options?.metadata, + }); + return { + key, + size: buf.length, + contentType: options?.contentType, + lastModified: new Date(), + metadata: options?.metadata, + }; + } + + async download(key: string): Promise { + const container = await this.containerClient(); + const blob = container.getBlobClient(key); + const response = await blob.downloadToBuffer(); + return response; + } + + async delete(key: string): Promise { + const container = await this.containerClient(); + const blob = container.getBlobClient(key); + await blob.deleteIfExists(); + } + + async exists(key: string): Promise { + const container = await this.containerClient(); + const blob = container.getBlobClient(key); + return blob.exists(); + } + + async list(prefix?: string): Promise { + const container = await this.containerClient(); + const results: BlobMeta[] = []; + for await (const blob of container.listBlobsFlat({ prefix: prefix ?? undefined })) { + results.push({ + key: blob.name, + size: blob.properties.contentLength ?? undefined, + contentType: blob.properties.contentType ?? undefined, + lastModified: blob.properties.lastModified, + }); + } + return results; + } + + async getSignedUrl(key: string, options?: SignedUrlOptions): Promise { + const { generateBlobSASQueryParameters, BlobSASPermissions, StorageSharedKeyCredential } = + await import('@azure/storage-blob'); + + if (!this.config.accountName || !this.config.accountKey) { + throw new Error('Signed URLs require accountName + accountKey'); + } + + const cred = new StorageSharedKeyCredential(this.config.accountName, this.config.accountKey); + const expiresOn = new Date(Date.now() + (options?.expiresIn ?? 3600) * 1000); + const permissions = BlobSASPermissions.parse(options?.permissions === 'write' ? 'w' : 'r'); + + const sas = generateBlobSASQueryParameters( + { + containerName: this.containerName, + blobName: key, + permissions, + expiresOn, + }, + cred + ); + + return `https://${this.config.accountName}.blob.core.windows.net/${this.containerName}/${key}?${sas.toString()}`; + } +} diff --git a/packages/storage/src/providers/memory.ts b/packages/storage/src/providers/memory.ts new file mode 100644 index 00000000..4bb2710d --- /dev/null +++ b/packages/storage/src/providers/memory.ts @@ -0,0 +1,83 @@ +/** + * In-memory storage provider — for testing and local dev. + */ + +import type { + BlobMeta, + SignedUrlOptions, + StorageBucket, + StorageProvider, + UploadOptions, +} from '../types.js'; + +export class MemoryStorageProvider implements StorageProvider { + private buckets = new Map(); + + getBucket(name: string): StorageBucket { + let bucket = this.buckets.get(name); + if (!bucket) { + bucket = new MemoryBucket(name); + this.buckets.set(name, bucket); + } + return bucket; + } + + async isHealthy(): Promise { + return true; + } + + clear(): void { + this.buckets.clear(); + } +} + +class MemoryBucket implements StorageBucket { + private blobs = new Map(); + + constructor(private name: string) {} + + async upload( + key: string, + data: Buffer | Uint8Array | string, + options?: UploadOptions + ): Promise { + const buf = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data); + const meta: BlobMeta = { + key, + size: buf.length, + contentType: options?.contentType ?? 'application/octet-stream', + lastModified: new Date(), + metadata: options?.metadata, + }; + this.blobs.set(key, { data: buf, meta }); + return meta; + } + + async download(key: string): Promise { + const entry = this.blobs.get(key); + if (!entry) throw new Error(`Blob '${key}' not found in bucket '${this.name}'`); + return entry.data; + } + + async delete(key: string): Promise { + this.blobs.delete(key); + } + + async exists(key: string): Promise { + return this.blobs.has(key); + } + + async list(prefix?: string): Promise { + const results: BlobMeta[] = []; + for (const [key, entry] of this.blobs) { + if (!prefix || key.startsWith(prefix)) { + results.push(entry.meta); + } + } + return results; + } + + async getSignedUrl(key: string, _options?: SignedUrlOptions): Promise { + return `memory://${this.name}/${key}?signed=true`; + } +} diff --git a/packages/storage/src/testing.ts b/packages/storage/src/testing.ts new file mode 100644 index 00000000..c3cecffb --- /dev/null +++ b/packages/storage/src/testing.ts @@ -0,0 +1,24 @@ +/** + * Test helpers for @bytelyst/storage. + */ + +import { setStorage, _resetStorage } from './factory.js'; +import { MemoryStorageProvider } from './providers/memory.js'; + +let _testProvider: MemoryStorageProvider | null = null; + +export function setTestStorageProvider(): MemoryStorageProvider { + _testProvider = new MemoryStorageProvider(); + setStorage(_testProvider); + return _testProvider; +} + +export function clearTestStorage(): void { + _testProvider?.clear(); +} + +export function resetTestStorage(): void { + _testProvider?.clear(); + _testProvider = null; + _resetStorage(); +} diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts new file mode 100644 index 00000000..b1f72112 --- /dev/null +++ b/packages/storage/src/types.ts @@ -0,0 +1,59 @@ +/** + * Cloud-agnostic storage interfaces. + * + * Provides StorageProvider and StorageBucket abstractions + * that work with Azure Blob, S3, R2, or in-memory storage. + */ + +export interface StorageProvider { + /** Get or create a bucket/container. */ + getBucket(name: string): StorageBucket; + + /** Check if storage is configured and reachable. */ + isHealthy(): Promise; +} + +export interface StorageBucket { + /** Upload a blob. */ + upload( + key: string, + data: Buffer | Uint8Array | string, + options?: UploadOptions + ): Promise; + + /** Download a blob as a Buffer. */ + download(key: string): Promise; + + /** Delete a blob. */ + delete(key: string): Promise; + + /** Check if a blob exists. */ + exists(key: string): Promise; + + /** List blobs with optional prefix. */ + list(prefix?: string): Promise; + + /** Get a signed URL for temporary access. */ + getSignedUrl(key: string, options?: SignedUrlOptions): Promise; +} + +export interface UploadOptions { + contentType?: string; + metadata?: Record; +} + +export interface SignedUrlOptions { + /** Expiry in seconds (default 3600). */ + expiresIn?: number; + permissions?: 'read' | 'write'; +} + +export interface BlobMeta { + key: string; + size?: number; + contentType?: string; + lastModified?: Date; + metadata?: Record; +} + +export type StorageProviderType = 'azure' | 'memory'; diff --git a/packages/storage/tsconfig.json b/packages/storage/tsconfig.json new file mode 100644 index 00000000..5edad813 --- /dev/null +++ b/packages/storage/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b84c88be..248a9cb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,7 +182,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) husky: specifier: ^9.0.0 version: 9.1.7 @@ -276,7 +276,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) husky: specifier: ^9.0.0 version: 9.1.7 @@ -342,6 +342,16 @@ importers: specifier: '>=4.0.0' version: 4.9.1(@azure/core-client@1.10.1) + packages/datastore: + dependencies: + '@azure/cosmos': + specifier: '>=4.0.0' + version: 4.9.1(@azure/core-client@1.10.1) + devDependencies: + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/design-tokens: devDependencies: tsx: @@ -388,6 +398,12 @@ importers: packages/kill-switch-client: {} + packages/llm: + devDependencies: + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/logger: {} packages/monitoring: {} @@ -396,6 +412,12 @@ importers: packages/platform-client: {} + packages/push: + devDependencies: + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/react-auth: dependencies: '@bytelyst/api-client': @@ -421,6 +443,16 @@ importers: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) + packages/storage: + dependencies: + '@azure/storage-blob': + specifier: '>=12.0.0' + version: 12.31.0 + devDependencies: + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/telemetry-client: {} packages/testing: