feat(packages): add cloud-agnostic abstraction packages — datastore, storage, llm, push + refactor secrets (58 tests)

This commit is contained in:
saravanakumardb1 2026-03-02 00:43:06 -08:00
parent 4fe0c034c2
commit dfa5eb73fa
41 changed files with 2566 additions and 19 deletions

View File

@ -7,7 +7,9 @@ export {
type ProductIdentity, type ProductIdentity,
} from './product-identity.js'; } from './product-identity.js';
export { export {
resolveSecrets,
resolveKeyVaultSecrets, resolveKeyVaultSecrets,
LYSNR_SECRETS, LYSNR_SECRETS,
type SecretMapping, type SecretMapping,
type SecretsProviderType,
} from './keyvault.js'; } from './keyvault.js';

View File

@ -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 * Call resolveSecrets() BEFORE Zod config parsing to populate
* process.env from Key Vault. Falls back gracefully when AKV is unavailable. * process.env from a secrets provider. Falls back gracefully when unavailable.
* *
* Requires: AZURE_KEYVAULT_URL env var (skip if not set). * Provider selection via SECRETS_PROVIDER env var:
* Auth: DefaultAzureCredential (managed identity in prod, az cli locally). * - '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 { 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; kvName: string;
/** Environment variable name to populate (e.g. 'COSMOS_KEY') */ /** Environment variable name to populate (e.g. 'COSMOS_KEY') */
envVar: string; 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). * - 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. * - Logs warnings but does NOT throw services fall back to .env values.
* *
* @param secrets - Array of {kvName, envVar} mappings * @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<void> {
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( export async function resolveKeyVaultSecrets(
secrets: SecretMapping[], secrets: SecretMapping[],
opts?: { vaultUrl?: string }, opts?: { vaultUrl?: string }
): Promise<void> {
return resolveAzureKeyVaultSecrets(secrets, opts);
}
/**
* Azure Key Vault implementation.
*/
async function resolveAzureKeyVaultSecrets(
secrets: SecretMapping[],
opts?: { vaultUrl?: string }
): Promise<void> { ): Promise<void> {
const vaultUrl = opts?.vaultUrl || process.env.AZURE_KEYVAULT_URL; const vaultUrl = opts?.vaultUrl || process.env.AZURE_KEYVAULT_URL;
if (!vaultUrl) return; // No KV configured — use env vars as-is if (!vaultUrl) return; // No KV configured — use env vars as-is
// Filter to only secrets that are missing from env // 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 if (missing.length === 0) return; // All secrets already in env
try { try {
@ -43,22 +88,22 @@ export async function resolveKeyVaultSecrets(
const client = new SecretClient(vaultUrl, new DefaultAzureCredential()); const client = new SecretClient(vaultUrl, new DefaultAzureCredential());
const results = await Promise.allSettled( const results = await Promise.allSettled(
missing.map(async (s) => { missing.map(async s => {
const secret = await client.getSecret(s.kvName); const secret = await client.getSecret(s.kvName);
if (secret.value) { if (secret.value) {
process.env[s.envVar] = 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) { if (failures.length > 0) {
console.warn( 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) { } catch {
console.warn(`[keyvault] Unable to connect to Key Vault at ${vaultUrl} — using env vars`); console.warn(`[secrets] Unable to connect to Key Vault at ${vaultUrl} — using env vars`);
} }
} }

View File

@ -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"
}
}

View File

@ -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<TestDoc>;
beforeEach(() => {
provider = new MemoryDatastoreProvider();
collection = provider.getCollection<TestDoc>('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('');
});
});

View File

@ -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<DatastoreProvider> {
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<DatastoreProvider> {
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;
}

View File

@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>)[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,
};
}

View File

@ -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';

View File

@ -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<string, CosmosCollection<BaseDocument>>();
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<T extends BaseDocument = BaseDocument>(
name: string,
partitionKeyPath: string
): DocumentCollection<T> {
const cacheKey = `${name}:${partitionKeyPath}`;
let collection = this.collections.get(cacheKey);
if (!collection) {
collection = new CosmosCollection<BaseDocument>(name, partitionKeyPath, () =>
this.getDatabase()
);
this.collections.set(cacheKey, collection);
}
return collection as unknown as DocumentCollection<T>;
}
async isHealthy(): Promise<boolean> {
try {
const db = await this.getDatabase();
await db.read();
return true;
} catch {
return false;
}
}
}
class CosmosCollection<T extends BaseDocument> implements DocumentCollection<T> {
constructor(
private containerName: string,
private partitionKeyPath: string,
private getDatabase: () => Promise<import('@azure/cosmos').Database>
) {}
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<T | null> {
try {
const c = await this.container();
const { resource } = await c.item(id, partitionKey).read<T>();
return resource ?? null;
} catch (err: unknown) {
if ((err as { code?: number })?.code === 404) return null;
throw err;
}
}
async findMany(query?: CollectionQuery<T>): Promise<T[]> {
const c = await this.container();
const sql = this.buildSelectSQL(query);
const { resources } = await c.items
.query<T>({
query: sql.query,
parameters: sql.parameters as import('@azure/cosmos').SqlParameter[],
})
.fetchAll();
return resources;
}
async findOne(query?: CollectionQuery<T>): Promise<T | null> {
const results = await this.findMany({ ...query, limit: 1 });
return results[0] ?? null;
}
async count(filter?: FilterMap): Promise<number> {
const c = await this.container();
const whereClause = filter ? filterToCosmosSQL(filter) : { query: '', parameters: [] };
const { resources } = await c.items
.query<number>({
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<T> {
const c = await this.container();
const { resource } = await c.items.create<T>(doc);
return resource as T;
}
async update(id: string, partitionKey: string, updates: Partial<T>): Promise<T> {
const c = await this.container();
const { resource: existing } = await c.item(id, partitionKey).read<T>();
if (!existing) throw new Error(`Document '${id}' not found`);
const merged = { ...existing, ...updates } as T;
const { resource } = await c.item(id, partitionKey).replace<T>(merged);
return resource as T;
}
async upsert(doc: T): Promise<T> {
const c = await this.container();
const { resource } = await c.items.upsert<T>(doc);
return resource as T;
}
async delete(id: string, partitionKey: string): Promise<void> {
const c = await this.container();
await c.item(id, partitionKey).delete();
}
async aggregate<R = Record<string, unknown>>(query: AggregateQuery): Promise<R[]> {
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<R>({
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<R = unknown>(query: string, parameters?: Record<string, unknown>): Promise<R[]> {
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<R>({
query,
parameters: cosmosParams as import('@azure/cosmos').SqlParameter[],
})
.fetchAll();
return resources;
}
private buildSelectSQL(query?: CollectionQuery<T>): {
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;
}

View File

@ -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<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj)) as T;
}
export class MemoryDatastoreProvider implements DatastoreProvider {
private collections = new Map<string, MemoryCollection<BaseDocument>>();
getCollection<T extends BaseDocument = BaseDocument>(
name: string,
_partitionKeyPath: string
): DocumentCollection<T> {
let collection = this.collections.get(name);
if (!collection) {
collection = new MemoryCollection<BaseDocument>();
this.collections.set(name, collection);
}
return collection as unknown as DocumentCollection<T>;
}
async isHealthy(): Promise<boolean> {
return true;
}
/** Clear all collections (for test cleanup). */
clear(): void {
this.collections.clear();
}
}
class MemoryCollection<T extends BaseDocument> implements DocumentCollection<T> {
private docs = new Map<string, T>();
async findById(id: string, _partitionKey: string): Promise<T | null> {
return this.docs.get(id) ?? null;
}
async findMany(query?: CollectionQuery<T>): Promise<T[]> {
let results = [...this.docs.values()];
if (query?.filter) {
results = results.filter(doc => matchesFilter(doc as Record<string, unknown>, 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<string, unknown>)[field];
const bVal = (b as Record<string, unknown>)[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<string, unknown> = {};
for (const key of query.select!) {
picked[key as string] = (doc as Record<string, unknown>)[key as string];
}
return picked as T;
});
}
return results;
}
async findOne(query?: CollectionQuery<T>): Promise<T | null> {
const results = await this.findMany({ ...query, limit: 1 });
return results[0] ?? null;
}
async count(filter?: FilterMap): Promise<number> {
if (!filter) return this.docs.size;
const results = [...this.docs.values()].filter(doc =>
matchesFilter(doc as Record<string, unknown>, filter)
);
return results.length;
}
async create(doc: T): Promise<T> {
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<T>): Promise<T> {
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<T> {
const clone = deepClone(doc);
this.docs.set(doc.id, clone);
return clone;
}
async delete(id: string, _partitionKey: string): Promise<void> {
this.docs.delete(id);
}
async aggregate<R = Record<string, unknown>>(query: AggregateQuery): Promise<R[]> {
let docs = [...this.docs.values()];
if (query.filter) {
docs = docs.filter(doc => matchesFilter(doc as Record<string, unknown>, query.filter!));
}
// Group by field
const groups = new Map<string, T[]>();
for (const doc of docs) {
const key = String((doc as Record<string, unknown>)[query.groupBy] ?? '__null__');
const group = groups.get(key) ?? [];
group.push(doc);
groups.set(key, group);
}
const results: Record<string, unknown>[] = [];
for (const [groupKey, groupDocs] of groups) {
const row: Record<string, unknown> = { [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<R = unknown>(_query: string, _parameters?: Record<string, unknown>): Promise<R[]> {
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<T extends BaseDocument>(
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<string, unknown>)[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);
}
}

View File

@ -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<T extends BaseDocument>(
collection: DocumentCollection<T>,
docs: T[]
): Promise<void> {
for (const doc of docs) {
await collection.create(doc);
}
}

View File

@ -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<T = BaseDocument> {
filter?: FilterMap;
sort?: Partial<Record<keyof T & string, 1 | -1>>;
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<T extends BaseDocument = BaseDocument> {
/** Find a single document by ID + partition key. */
findById(id: string, partitionKey: string): Promise<T | null>;
/** Find multiple documents matching a query. */
findMany(query?: CollectionQuery<T>): Promise<T[]>;
/** Find the first document matching a query. */
findOne(query?: CollectionQuery<T>): Promise<T | null>;
/** Count documents matching a filter. */
count(filter?: FilterMap): Promise<number>;
/** Create a new document. */
create(doc: T): Promise<T>;
/** Update a document by ID + partition key (merge semantics). */
update(id: string, partitionKey: string, updates: Partial<T>): Promise<T>;
/** Upsert a document (create or replace). */
upsert(doc: T): Promise<T>;
/** Delete a document by ID + partition key. */
delete(id: string, partitionKey: string): Promise<void>;
/** Run an aggregation query. */
aggregate<R = Record<string, unknown>>(query: AggregateQuery): Promise<R[]>;
/**
* Execute a raw provider-specific query (escape hatch).
* Returns results as-is from the underlying provider.
*/
rawQuery<R = unknown>(query: string, parameters?: Record<string, unknown>): Promise<R[]>;
}
// ── Datastore provider interface ────────────────────────────────────────────
export interface DatastoreProvider {
/** Get a collection by name with a partition key path. */
getCollection<T extends BaseDocument = BaseDocument>(
name: string,
partitionKeyPath: string
): DocumentCollection<T>;
/** Check if the datastore is healthy / reachable. */
isHealthy(): Promise<boolean>;
}
// ── Provider config ─────────────────────────────────────────────────────────
export type DatastoreProviderType = 'cosmos' | 'memory';

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

27
packages/llm/package.json Normal file
View File

@ -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"
}
}

View File

@ -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');
});
});

View File

@ -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;
}

13
packages/llm/src/index.ts Normal file
View File

@ -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';

View File

@ -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<AzureOpenAIConfig>) {
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<ChatCompletionResponse> {
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,
},
};
}
}

View File

@ -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<ChatCompletionResponse> {
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 = [];
}
}

View File

@ -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<OpenAIConfig>) {
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<ChatCompletionResponse> {
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,
},
};
}
}

View File

@ -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';

48
packages/llm/src/types.ts Normal file
View File

@ -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<ChatCompletionResponse>;
/** Stream a chat completion response. */
chatCompletionStream?(req: ChatCompletionRequest): AsyncIterable<string>;
/** 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';

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View File

@ -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"
}
}

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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';

View File

@ -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<PushResult> {
const results = await this.sendBatch([notification]);
return results[0]!;
}
async sendBatch(notifications: PushNotification[]): Promise<PushResult[]> {
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',
}));
}
}
}

View File

@ -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<PushResult> {
this.sent.push(notification);
return { success: true, messageId: `mock-${Date.now()}-${this.sent.length}` };
}
async sendBatch(notifications: PushNotification[]): Promise<PushResult[]> {
return Promise.all(notifications.map(n => this.send(n)));
}
reset(): void {
this.sent = [];
}
}

View File

@ -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';

View File

@ -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<PushResult>;
/** Send a batch of push notifications. */
sendBatch(notifications: PushNotification[]): Promise<PushResult[]>;
/** Check if the push provider is configured. */
isConfigured(): boolean;
}
export interface PushNotification {
deviceToken: string;
platform: 'ios' | 'android' | 'web';
title: string;
body: string;
data?: Record<string, string>;
badge?: number;
sound?: string;
channelId?: string;
}
export interface PushResult {
success: boolean;
messageId?: string;
error?: string;
}
export type PushProviderType = 'firebase' | 'expo' | 'mock';

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View File

@ -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"
}
}

View File

@ -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');
});
});
});

View File

@ -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<StorageProvider> {
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<StorageProvider> {
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;
}

View File

@ -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';

View File

@ -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<string, AzureBlobBucket>();
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<boolean> {
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<import('@azure/storage-blob').BlobServiceClient>,
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<BlobMeta> {
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<Buffer> {
const container = await this.containerClient();
const blob = container.getBlobClient(key);
const response = await blob.downloadToBuffer();
return response;
}
async delete(key: string): Promise<void> {
const container = await this.containerClient();
const blob = container.getBlobClient(key);
await blob.deleteIfExists();
}
async exists(key: string): Promise<boolean> {
const container = await this.containerClient();
const blob = container.getBlobClient(key);
return blob.exists();
}
async list(prefix?: string): Promise<BlobMeta[]> {
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<string> {
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()}`;
}
}

View File

@ -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<string, MemoryBucket>();
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<boolean> {
return true;
}
clear(): void {
this.buckets.clear();
}
}
class MemoryBucket implements StorageBucket {
private blobs = new Map<string, { data: Buffer; meta: BlobMeta }>();
constructor(private name: string) {}
async upload(
key: string,
data: Buffer | Uint8Array | string,
options?: UploadOptions
): Promise<BlobMeta> {
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<Buffer> {
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<void> {
this.blobs.delete(key);
}
async exists(key: string): Promise<boolean> {
return this.blobs.has(key);
}
async list(prefix?: string): Promise<BlobMeta[]> {
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<string> {
return `memory://${this.name}/${key}?signed=true`;
}
}

View File

@ -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();
}

View File

@ -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<boolean>;
}
export interface StorageBucket {
/** Upload a blob. */
upload(
key: string,
data: Buffer | Uint8Array | string,
options?: UploadOptions
): Promise<BlobMeta>;
/** Download a blob as a Buffer. */
download(key: string): Promise<Buffer>;
/** Delete a blob. */
delete(key: string): Promise<void>;
/** Check if a blob exists. */
exists(key: string): Promise<boolean>;
/** List blobs with optional prefix. */
list(prefix?: string): Promise<BlobMeta[]>;
/** Get a signed URL for temporary access. */
getSignedUrl(key: string, options?: SignedUrlOptions): Promise<string>;
}
export interface UploadOptions {
contentType?: string;
metadata?: Record<string, string>;
}
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<string, string>;
}
export type StorageProviderType = 'azure' | 'memory';

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

36
pnpm-lock.yaml generated
View File

@ -182,7 +182,7 @@ importers:
version: 9.39.2(jiti@2.6.1) version: 9.39.2(jiti@2.6.1)
eslint-config-next: eslint-config-next:
specifier: 16.1.6 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: husky:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.1.7 version: 9.1.7
@ -276,7 +276,7 @@ importers:
version: 9.39.2(jiti@2.6.1) version: 9.39.2(jiti@2.6.1)
eslint-config-next: eslint-config-next:
specifier: 16.1.6 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: husky:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.1.7 version: 9.1.7
@ -342,6 +342,16 @@ importers:
specifier: '>=4.0.0' specifier: '>=4.0.0'
version: 4.9.1(@azure/core-client@1.10.1) 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: packages/design-tokens:
devDependencies: devDependencies:
tsx: tsx:
@ -388,6 +398,12 @@ importers:
packages/kill-switch-client: {} 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/logger: {}
packages/monitoring: {} packages/monitoring: {}
@ -396,6 +412,12 @@ importers:
packages/platform-client: {} 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: packages/react-auth:
dependencies: dependencies:
'@bytelyst/api-client': '@bytelyst/api-client':
@ -421,6 +443,16 @@ importers:
specifier: ^19.2.4 specifier: ^19.2.4
version: 19.2.4(react@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/telemetry-client: {}
packages/testing: packages/testing: