feat(packages): add cloud-agnostic abstraction packages — datastore, storage, llm, push + refactor secrets (58 tests)
This commit is contained in:
parent
4fe0c034c2
commit
dfa5eb73fa
@ -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';
|
||||||
|
|||||||
@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
packages/datastore/package.json
Normal file
35
packages/datastore/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
278
packages/datastore/src/__tests__/memory.test.ts
Normal file
278
packages/datastore/src/__tests__/memory.test.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
59
packages/datastore/src/factory.ts
Normal file
59
packages/datastore/src/factory.ts
Normal 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;
|
||||||
|
}
|
||||||
178
packages/datastore/src/filter.ts
Normal file
178
packages/datastore/src/filter.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
18
packages/datastore/src/index.ts
Normal file
18
packages/datastore/src/index.ts
Normal 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';
|
||||||
243
packages/datastore/src/providers/cosmos.ts
Normal file
243
packages/datastore/src/providers/cosmos.ts
Normal 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;
|
||||||
|
}
|
||||||
202
packages/datastore/src/providers/memory.ts
Normal file
202
packages/datastore/src/providers/memory.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
packages/datastore/src/testing.ts
Normal file
53
packages/datastore/src/testing.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
packages/datastore/src/types.ts
Normal file
113
packages/datastore/src/types.ts
Normal 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';
|
||||||
9
packages/datastore/tsconfig.json
Normal file
9
packages/datastore/tsconfig.json
Normal 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
27
packages/llm/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
76
packages/llm/src/__tests__/llm.test.ts
Normal file
76
packages/llm/src/__tests__/llm.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
73
packages/llm/src/factory.ts
Normal file
73
packages/llm/src/factory.ts
Normal 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
13
packages/llm/src/index.ts
Normal 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';
|
||||||
88
packages/llm/src/providers/azure-openai.ts
Normal file
88
packages/llm/src/providers/azure-openai.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
48
packages/llm/src/providers/mock.ts
Normal file
48
packages/llm/src/providers/mock.ts
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
81
packages/llm/src/providers/openai.ts
Normal file
81
packages/llm/src/providers/openai.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
packages/llm/src/testing.ts
Normal file
18
packages/llm/src/testing.ts
Normal 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
48
packages/llm/src/types.ts
Normal 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';
|
||||||
9
packages/llm/tsconfig.json
Normal file
9
packages/llm/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
27
packages/push/package.json
Normal file
27
packages/push/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
packages/push/src/__tests__/push.test.ts
Normal file
47
packages/push/src/__tests__/push.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
packages/push/src/factory.ts
Normal file
41
packages/push/src/factory.ts
Normal 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;
|
||||||
|
}
|
||||||
5
packages/push/src/index.ts
Normal file
5
packages/push/src/index.ts
Normal 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';
|
||||||
64
packages/push/src/providers/expo.ts
Normal file
64
packages/push/src/providers/expo.ts
Normal 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',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
packages/push/src/providers/mock.ts
Normal file
28
packages/push/src/providers/mock.ts
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
18
packages/push/src/testing.ts
Normal file
18
packages/push/src/testing.ts
Normal 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';
|
||||||
36
packages/push/src/types.ts
Normal file
36
packages/push/src/types.ts
Normal 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';
|
||||||
9
packages/push/tsconfig.json
Normal file
9
packages/push/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
35
packages/storage/package.json
Normal file
35
packages/storage/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
packages/storage/src/__tests__/memory-storage.test.ts
Normal file
80
packages/storage/src/__tests__/memory-storage.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
52
packages/storage/src/factory.ts
Normal file
52
packages/storage/src/factory.ts
Normal 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;
|
||||||
|
}
|
||||||
12
packages/storage/src/index.ts
Normal file
12
packages/storage/src/index.ts
Normal 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';
|
||||||
170
packages/storage/src/providers/azure-blob.ts
Normal file
170
packages/storage/src/providers/azure-blob.ts
Normal 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()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
packages/storage/src/providers/memory.ts
Normal file
83
packages/storage/src/providers/memory.ts
Normal 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/storage/src/testing.ts
Normal file
24
packages/storage/src/testing.ts
Normal 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();
|
||||||
|
}
|
||||||
59
packages/storage/src/types.ts
Normal file
59
packages/storage/src/types.ts
Normal 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';
|
||||||
9
packages/storage/tsconfig.json
Normal file
9
packages/storage/tsconfig.json
Normal 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
36
pnpm-lock.yaml
generated
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user