/** * 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, ConcurrencyToken, DatastoreProvider, DocumentCollection, FilterMap, UpdateIfMatchResult, } from '../types.js'; export interface CosmosProviderConfig { endpoint: string; key: string; database: string; } export class CosmosDatastoreProvider implements DatastoreProvider { private client: import('@azure/cosmos').CosmosClient | null = null; private databaseRef: import('@azure/cosmos').Database | null = null; private config: CosmosProviderConfig; private collections = new Map>(); constructor(config?: CosmosProviderConfig) { this.config = config ?? { endpoint: getEnvOrThrow('COSMOS_ENDPOINT'), key: getEnvOrThrow('COSMOS_KEY'), database: process.env.COSMOS_DATABASE || 'lysnrai', }; } private async getDatabase() { if (!this.databaseRef) { const { CosmosClient } = await import('@azure/cosmos'); this.client = new CosmosClient({ endpoint: this.config.endpoint, key: this.config.key, }); this.databaseRef = this.client.database(this.config.database); } return this.databaseRef as import('@azure/cosmos').Database; } getCollection( name: string, partitionKeyPath: string ): DocumentCollection { const cacheKey = `${name}:${partitionKeyPath}`; let collection = this.collections.get(cacheKey); if (!collection) { collection = new CosmosCollection(name, partitionKeyPath, () => this.getDatabase() ); this.collections.set(cacheKey, collection); } return collection as unknown as DocumentCollection; } async isHealthy(): Promise { try { const db = await this.getDatabase(); await db.read(); return true; } catch { return false; } } } class CosmosCollection implements DocumentCollection { constructor( private containerName: string, private partitionKeyPath: string, private getDatabase: () => Promise ) {} private async container() { const db = await this.getDatabase(); return db.container(this.containerName); } private pkField(): string { // Convert /userId to userId, /id to id, etc. return this.partitionKeyPath.replace(/^\//, ''); } async findById(id: string, partitionKey: string): Promise { try { const c = await this.container(); const { resource } = await c.item(id, partitionKey).read(); return resource ?? null; } catch (err: unknown) { if ((err as { code?: number })?.code === 404) return null; throw err; } } async findMany(query?: CollectionQuery): Promise { const c = await this.container(); const sql = this.buildSelectSQL(query); const { resources } = await c.items .query({ query: sql.query, parameters: sql.parameters as import('@azure/cosmos').SqlParameter[], }) .fetchAll(); return resources; } async findOne(query?: CollectionQuery): Promise { const results = await this.findMany({ ...query, limit: 1 }); return results[0] ?? null; } async count(filter?: FilterMap): Promise { const c = await this.container(); const whereClause = filter ? filterToCosmosSQL(filter) : { query: '', parameters: [] }; const { resources } = await c.items .query({ query: `SELECT VALUE COUNT(1) FROM c ${whereClause.query}`, parameters: whereClause.parameters as import('@azure/cosmos').SqlParameter[], }) .fetchAll(); return resources[0] ?? 0; } async create(doc: T): Promise { const c = await this.container(); const { resource } = await c.items.create(doc); return resource as T; } async update(id: string, partitionKey: string, updates: Partial): Promise { const c = await this.container(); const { resource: existing } = await c.item(id, partitionKey).read(); if (!existing) throw new Error(`Document '${id}' not found`); const merged = { ...existing, ...updates } as T; const { resource } = await c.item(id, partitionKey).replace(merged); return resource as T; } /** * Atomic compare-and-set via Cosmos optimistic concurrency. Conditions the * replace on the document `_etag` (`accessCondition: { type: 'IfMatch' }`), so * the server rejects the write (HTTP 412) if any other writer committed since * the version this caller read — exactly one concurrent claimer wins. A 412 maps * to `{ ok: false, reason: 'conflict' }`, a 404 to `'not_found'`. `rev` is also * compared (when supplied) and bumped, for parity with the memory provider. */ async updateIfMatch( id: string, partitionKey: string, expected: ConcurrencyToken, patch: Partial ): Promise> { const c = await this.container(); let existing: T | undefined; try { const read = await c.item(id, partitionKey).read(); existing = read.resource; } catch (err: unknown) { if ((err as { code?: number })?.code === 404) return { ok: false, reason: 'not_found' }; throw err; } if (!existing) return { ok: false, reason: 'not_found' }; const cur = existing as T & { rev?: number }; // If the caller pinned a rev and it already moved on, a concurrent writer // committed before our read — conflict without attempting the write. if (expected.rev !== undefined && (cur.rev ?? 0) !== expected.rev) { return { ok: false, reason: 'conflict' }; } // Condition the write on the caller's etag when given, else the just-read // etag — either way the IfMatch guards the read→replace window server-side. const condition = expected.etag ?? cur._etag; const nextRev = (cur.rev ?? 0) + 1; const merged = { ...existing, ...patch, rev: nextRev } as T; try { const { resource } = await c.item(id, partitionKey).replace(merged, { accessCondition: condition ? { type: 'IfMatch', condition } : undefined, }); return { ok: true, doc: resource as T }; } catch (err: unknown) { const code = (err as { code?: number })?.code; if (code === 412) return { ok: false, reason: 'conflict' }; if (code === 404) return { ok: false, reason: 'not_found' }; throw err; } } async upsert(doc: T): Promise { const c = await this.container(); const { resource } = await c.items.upsert(doc); return resource as T; } async delete(id: string, partitionKey: string): Promise { const c = await this.container(); await c.item(id, partitionKey).delete(); } async aggregate>(query: AggregateQuery): Promise { const c = await this.container(); const whereClause = query.filter ? filterToCosmosSQL(query.filter) : { query: '', parameters: [] }; const aggFields = query.aggregations .map(a => { const func = a.op === 'count' ? `COUNT(1)` : `${a.op.toUpperCase()}(c.${a.field})`; return `${func} AS ${a.alias}`; }) .join(', '); const { resources } = await c.items .query({ query: `SELECT c.${query.groupBy}, ${aggFields} FROM c ${whereClause.query} GROUP BY c.${query.groupBy}`, parameters: whereClause.parameters as import('@azure/cosmos').SqlParameter[], }) .fetchAll(); return resources; } async rawQuery(query: string, parameters?: Record): Promise { const c = await this.container(); const cosmosParams = parameters ? Object.entries(parameters).map(([name, value]) => ({ name: name.startsWith('@') ? name : `@${name}`, value, })) : []; const { resources } = await c.items .query({ query, parameters: cosmosParams as import('@azure/cosmos').SqlParameter[], }) .fetchAll(); return resources; } private buildSelectSQL(query?: CollectionQuery): { query: string; parameters: Array<{ name: string; value: unknown }>; } { const parts: string[] = []; // SELECT if (query?.select && query.select.length > 0) { const fields = query.select.map(f => `c.${f as string}`).join(', '); parts.push(`SELECT ${fields} FROM c`); } else { parts.push('SELECT * FROM c'); } // WHERE const whereClause = query?.filter ? filterToCosmosSQL(query.filter) : { query: '', parameters: [] }; const parameters = [...whereClause.parameters]; if (whereClause.query) parts.push(whereClause.query); // ORDER BY if (query?.sort) { const orderParts = Object.entries(query.sort) .map(([field, dir]) => `c.${field} ${dir === 1 ? 'ASC' : 'DESC'}`) .join(', '); if (orderParts) parts.push(`ORDER BY ${orderParts}`); } // OFFSET / LIMIT if (query?.offset !== undefined && query?.limit !== undefined) { parts.push(`OFFSET ${query.offset} LIMIT ${query.limit}`); } else if (query?.limit !== undefined) { parts.push(`OFFSET 0 LIMIT ${query.limit}`); } return { query: parts.join(' '), parameters }; } } function getEnvOrThrow(name: string): string { const value = process.env[name]; if (!value) throw new Error(`Environment variable ${name} is required for CosmosDatastoreProvider`); return value; }