CosmosQL

Complete Documentation

All documentation pages compiled into a single view for easy reference and LLM consumption.

FAQ

Frequently asked questions about CosmosQL

View on separate page →

Quick answers to common questions. For detailed guides, see Getting Started or Common Patterns.

Quick Decisions

Should I use CosmosQL?
Use CosmosQL if: TypeScript ✅ | Need type safety ✅ | Want minimal dependencies ✅
Use Azure SDK if: Need change feed/stored procedures ✅ | JavaScript only ✅

Can I migrate existing databases?
Yes. CosmosQL works with existing CosmosDB databases. No data migration needed—just define schemas matching your current documents.

Can I use both libraries?
Yes. CosmosQL and Azure SDK coexist—use CosmosQL for CRUD, Azure SDK for advanced features.

Features

What's supported?

  • ✅ CRUD operations (create, read, update, delete)
  • ✅ Partition-scoped and cross-partition queries
  • ✅ Point reads, filtering, sorting, pagination
  • ✅ Bulk operations within partitions
  • ✅ Raw SQL queries
  • ❌ Change feed processors
  • ❌ Stored procedures
  • ❌ Graph/MongoDB APIs

See Limitations for complete list.

Language & Runtime

JavaScript support?
No. CosmosQL requires TypeScript. The value is compile-time type safety.

Node.js version?
Node.js 18+. Uses modern built-in APIs.

Serverless?
Yes. Zero dependencies make CosmosQL ideal for Lambda, Azure Functions, Edge Runtime.

Performance

Overhead vs REST?
~1-2%. Direct REST API calls with compile-time type safety.

vs Azure SDK?
CosmosQL is typically faster due to zero runtime overhead. Azure SDK has some bulk operation optimizations.

How much does a query cost?

  • Point read: ~1 RU
  • Partition query: ~3-5 RU
  • Cross-partition: ~50-100+ RU

Schema Changes

Adding fields?
Update your schema. Existing documents work fine—new fields are optional.

Removing fields?
Treat as optional in schema. Can't truly remove from existing documents. Consider versioning.

Changing field types?
Version documents or write migration scripts. See Migration Guide for strategies.

Migration

From Azure SDK?
Gradual migration recommended. Use CosmosQL for new features, keep SDK for advanced features. See Migration Guide.

Schema design?
Start with Core Concepts to understand partition keys. Then see Common Patterns for examples.

Production

Production ready?
Yes. Stable API, 249 tests, 100% coverage. Used in production by multiple teams.

Error handling?
Standard try/catch with CosmosError class. See Advanced Patterns for examples.

Support?
GitHub issues, discussions, and documentation. Maintainers are responsive.

Comparison

vs Prisma?
CosmosQL is CosmosDB-specific with partition key enforcement. Prisma supports many databases.

vs Mongoose?
CosmosQL is TypeScript-native, compile-time validation. Mongoose is MongoDB with runtime validation.

See Limitations for complete comparison table.

Need More Help?

Introduction

Why CosmosQL exists and the problems it solves

View on separate page →

A type-safe, zero-dependency library for Azure CosmosDB. Catch expensive query mistakes at compile time, not when your bill arrives.

The Problem

CosmosDB is powerful but unforgiving. Three common mistakes:

1. Forgotten Partition Keys → $2,400/month mistake

A developer wrote a query without a partition key. With 1,000 users it cost $5/month. With 100,000 users it cost $2,400/month. CosmosDB had to scan every partition.

2. Type Errors at Runtime → Production bugs

Try to access user.emial instead of user.email? JavaScript won't catch it. The bug ships, users get undefined emails.

3. Heavy Dependencies → Slow cold starts

The official Azure SDK pulls in 50+ packages. For serverless functions, this means slower cold starts and bigger bundles.

The Solution

CosmosQL prevents these at compile time:

  • Partition keys required - TypeScript won't compile without them
  • Type-safe queries - Autocomplete catches field name typos
  • Zero dependencies - Just TypeScript, direct REST API calls
  • Prisma-inspired API - Familiar patterns with CosmosDB-specific optimizations
import { createClient, container, field } from 'cosmosql';

const users = container('users', {
  id: field.string(),
  email: field.string(),
  name: field.string()
}).partitionKey('email');

const db = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'myapp',
  mode: 'auto-create'
}).withContainers({ users });

// Point read (1 RU - cheapest possible)
const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' }
});

// Query within partition (5 RU - still cheap)
const posts = await db.posts.findMany({
  partitionKey: 'user_123',
  where: { isPublished: true }
});

// TypeScript knows all return types automatically

Built-In Features

CosmosQL includes everything you need for production:

Migrations - Structured schema evolution with rollback support:

const addPreferences = defineMigration({
  version: 1,
  name: 'add-user-preferences',
  async up({ db, logger }) {
    await db.users.updateMany({
      where: {},
      data: (doc) => ({ preferences: { theme: 'light' } }),
      enableCrossPartitionQuery: true
    });
  }
});

await db.migrations.apply({ target: 'latest', confirm: true });

Database Management - Health checks, schema validation, and container management:

// Health check
const health = await db.management.healthCheck();
console.log(`Health: ${health.overallHealth}`);

// Schema diff
const diff = await db.management.diffSchema();
if (diff.requiresAction) {
  console.log('Schema drift detected!');
}

Bulk Operations - Efficient batch updates and deletes with progress tracking:

// Update many documents
await db.users.updateMany({
  where: { isActive: false },
  data: { status: 'archived' },
  enableCrossPartitionQuery: true,
  onProgress: (stats) => console.log(`${stats.percentage}%`)
});

Learn more: Migrations | Management | Bulk Operations

Is This For You?

Are you using TypeScript?

Need change feed or stored procedures?

  • Yes → Use the Azure SDK
  • No ↓

Want type safety and minimal dependencies?

  • Yes → Use CosmosQL

Quick Start

Get working code in 5 minutes:

npm install cosmosql

See the Quickstart Guide for your first query → understanding → customization flow.

Production Ready

Used in production by teams handling real traffic. Comprehensive test coverage ensures reliability.

Features:

  • 100% Type-Safe - Compile-time error catching for all operations
  • Zero Overhead - Direct REST API calls, minimal bundle size (43.6 kB packed)
  • Partition Key Aware - Enforces partition key usage to avoid expensive cross-partition queries
  • Zero Dependencies - Only TypeScript required, works with Node.js 18+ and Bun 1.0+
  • Built-in Migrations - Schema evolution with versioning and rollback support
  • Database Management - Health checks, schema validation, and container management
  • Bulk Operations - Efficient batch updates and deletes with progress tracking

Next Steps:

Contributing

Development setup, code quality, and contribution guidelines for CosmosQL

View on separate page →

Thank you for your interest in contributing to CosmosQL! This guide will help you get started with development.

Development Setup

Prerequisites

  • Node.js 18+ or Bun 1.0+
  • TypeScript 5.0+
  • Git for version control

Getting Started

  1. Clone the repository:
git clone https://github.com/Stoffberg/cosmosql.git
cd cosmosql
  1. Install dependencies:
# Using Bun (recommended)
bun install

# Or using npm
npm install
  1. Build the project:
bun run build

# Or
npm run build
  1. Run tests:
# All tests
bun test

# Unit tests only
bun test tests/unit

# Integration tests only
bun test tests/integration

Note: Integration tests require a real Azure CosmosDB account. Set up environment variables:

COSMOS_ENDPOINT=https://your-account.documents.azure.com:443/
COSMOS_KEY=your-key
COSMOS_DATABASE=test-db

Code Quality

CosmosQL maintains high code quality standards:

Linting and Formatting

Biome is used for linting and formatting:

# Check for issues
bun run lint

# Auto-fix issues
bun run format

TypeScript

  • Strict mode is enabled
  • All code must be fully typed
  • Avoid any types unless absolutely necessary

Testing

  • Comprehensive test suite required for all changes
  • Maintain high test coverage
  • Add tests for new features and bug fixes

Architecture

Understanding CosmosQL's architecture helps when contributing:

Core Principles

  1. Zero Dependencies - Only TypeScript, no external runtime dependencies
  2. Type-Level Programming - Leverage TypeScript's type system for schema inference
  3. REST API Direct - Direct HTTP calls to CosmosDB REST API, no SDK wrapper
  4. Bun Runtime Optimized - Built and tested with Bun, works with Node.js

Project Structure

cosmosql/
├── src/
│   ├── client/          # Client creation and configuration
│   ├── container/       # Container schema definitions
│   ├── query/           # Query builder and execution
│   ├── types/           # Type definitions and inference
│   └── utils/           # Utilities and helpers
├── tests/
│   ├── unit/            # Unit tests
│   └── integration/     # Integration tests with real CosmosDB
└── package.json

Key Concepts

  • Schema Definition: TypeScript-only schemas that don't exist at runtime
  • Type Inference: Automatic type inference from schemas
  • Partition Key Enforcement: Compile-time enforcement via TypeScript
  • Query Builder: Type-safe SQL query generation

Making Changes

Workflow

  1. Create a branch:
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix
  1. Make your changes - Write code, add tests

  2. Run tests and linting:

bun test
bun run lint
  1. Commit your changes:
git commit -m "feat: add new feature description"

Follow Conventional Commits format:

  • feat: - New feature
  • fix: - Bug fix
  • docs: - Documentation changes
  • test: - Test additions/changes
  • refactor: - Code refactoring
  • perf: - Performance improvements
  1. Push and create a PR:
git push origin feature/your-feature-name

Pull Request Guidelines

  • Clear description of what changed and why
  • Reference issues if fixing a bug or feature request
  • Add tests for new features or bug fixes
  • Update documentation if API changes or new features added
  • Ensure all tests pass and linting is clean

Testing Requirements

  • New features must include unit tests
  • Integration tests for database operations
  • Edge cases should be covered
  • Maintain or improve test coverage

Areas for Contribution

High Priority

  • Bug fixes - Report and fix bugs
  • Documentation - Improve clarity and completeness
  • Performance - Optimize queries and operations
  • Type safety - Enhance type inference and error messages

Feature Ideas

  • Aggregation functions (count, sum, avg, etc.)
  • Cursor-based pagination
  • Batch operations across partitions
  • Additional query operators

Documentation

  • Code examples
  • Best practices guides
  • Migration guides
  • API reference improvements

Getting Help

If you need help:

  1. Check existing issues on GitHub
  2. Ask questions in discussions
  3. Join Discord community (if available)
  4. Read existing code to understand patterns

License

CosmosQL is licensed under the MIT License. By contributing, you agree that your contributions will be licensed under the same license.


Thank you for contributing to CosmosQL! Your efforts help make CosmosDB development safer and more enjoyable for everyone.

Limitations

Current limitations of CosmosQL and planned features

View on separate page →

CosmosQL focuses on core CRUD operations and common query patterns that cover the majority of application use cases. This document outlines what's currently supported, what's missing, and workarounds for common scenarios.

Current Version Status

Production Ready: ✅ Yes - Core operations are stable and well-tested

Test Coverage: Comprehensive test suite covering all core functionality

Architecture:

  • Zero dependencies - Only TypeScript required
  • Type-level programming - Schema inference and type safety
  • REST API direct - No SDK wrapper, minimal overhead
  • Bun runtime - Optimized for Bun 1.0+ and Node.js 18+

Missing Features

1. Advanced Pagination

CosmosQL currently only supports offset-based pagination using take and skip. Cursor-based pagination and continuation tokens are not yet supported.

Current Support:

// Offset pagination (LIMIT/OFFSET)
const page1 = await db.posts.findMany({
  partitionKey: 'user_123',
  take: 20,
  skip: 0
});

const page2 = await db.posts.findMany({
  partitionKey: 'user_123',
  take: 20,
  skip: 20
});

Limitations:

  • No cursor-based pagination
  • No continuation token support
  • Cross-partition queries with large offsets can be expensive

Workaround: For large datasets, consider:

  1. Partition-scoped queries - Stay within partitions to minimize costs
  2. Date/time-based filtering - Use timestamp filters instead of large offsets
  3. Manual continuation tokens - Use raw SQL queries with continuation tokens (advanced)

Future Plans: Cursor-based pagination and continuation token support are planned.

2. Transaction Support

CosmosQL does not support:

  • Multi-document transactions
  • Batch operations beyond createMany (which requires same partition key)
  • Transaction rollback capabilities
  • Cross-partition transactions

Current Support:

// Single-partition batch operations only
await db.posts.createMany({
  data: [
    { id: 'post_1', userId: 'user_123', title: 'Post 1' },
    { id: 'post_2', userId: 'user_123', title: 'Post 2' }
  ],
  partitionKey: 'user_123' // All documents must share partition key
});

Limitations:

  • Cannot atomically update multiple documents across partitions
  • No rollback support
  • Limited to operations within a single partition

Workaround: For multi-document operations:

  1. Design for single-partition operations - Structure data so related documents share partition keys
  2. Use application-level transactions - Implement compensating actions for failures
  3. Use Azure SDK for transactions - For complex scenarios, use the official SDK alongside CosmosQL

Future Plans: Transaction support is under consideration for future releases.

3. Cross-Partition Query Limitations

Azure CosmosDB Restriction: Cross-partition queries are limited on empty containers. This is a CosmosDB limitation, not a CosmosQL limitation.

When attempting cross-partition queries on empty containers, CosmosDB returns an error. This is by design to prevent expensive operations on empty data.

Workaround: Ensure containers have at least one document before running cross-partition queries, or use partition-scoped queries when possible.

4. Advanced Query Features

The following CosmosDB features are not yet supported:

  • Geospatial queries - Spatial indexing is supported in schema definition but full geospatial queries require raw SQL
  • Full-text search - Integration with Azure Cognitive Search (not supported via REST API)
  • JOIN operations - Not applicable to document databases, but no support for array joins
  • Stored procedures - Cannot execute stored procedures (REST API focus)
  • User-defined functions (UDFs) - Cannot use UDFs in queries (REST API focus)

Workaround: Use raw SQL queries for features not supported by the query builder:

// Geospatial example (when supported via raw SQL)
const result = await db.locations.query({
  sql: `
    SELECT * FROM c 
    WHERE ST_DISTANCE(c.location, {
      'type': 'Point',
      'coordinates': [@lng, @lat]
    }) < @distance
  `,
  parameters: [
    { name: '@lng', value: -122.4194 },
    { name: '@lat', value: 37.7749 },
    { name: '@distance', value: 5000 }
  ]
});

Future Plans: Advanced query features may be added based on community demand.

5. Performance Optimizations

Missing performance-related features:

  • Query result caching - No built-in caching layer
  • Query plan analysis - No automatic query optimization suggestions
  • Automatic indexing suggestions - No recommendations for index optimization
  • Connection pool management - Basic pooling exists, but limited configuration

Workaround:

  1. Implement caching at application level - Use Redis or in-memory caching for frequently accessed data
  2. Monitor RU usage - Track Request Unit consumption in Azure Portal
  3. Optimize partition keys - Ensure good partition key selection (documented in Performance Guide)
  4. Use point reads - Prefer findUnique over findMany when querying single documents

Future Plans: Performance monitoring and optimization tools may be added.

6. Change Feed

CosmosQL does not support:

  • Change feed processors
  • Real-time change notifications
  • Document change tracking

Workaround: Use the official Azure SDK for change feed processing:

// Using Azure SDK for change feed
import { CosmosClient } from '@azure/cosmos';

const client = new CosmosClient(connectionString);
const container = client.database('mydb').container('users');

// Use SDK for change feed
const changeFeedProcessor = container.items
  .changeFeed({
    startFromBeginning: false
  })
  .on('change', (changes) => {
    // Handle changes
  });

Future Plans: Change feed support is being considered for future releases.

7. Multi-Database Operations

CosmosQL works with a single database per client instance. Cross-database operations are not supported.

Workaround: Create multiple client instances:

const productionDb = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'production',
  mode: 'verify'
}).withContainers({ users, posts });

const analyticsDb = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'analytics',
  mode: 'verify'
}).withContainers({ events, metrics });

Feature Comparison

FeatureCosmosQLAzure SDKStatus
Type Safety✅ Full❌ LimitedCosmosQL advantage
CRUD Operations✅ Complete✅ CompleteEquivalent
Aggregations✅ Type-safe✅ NativeEquivalent
Transactions⚠️ Single partition✅ Full supportSDK advantage
Change Feed❌ Not supported✅ SupportedSDK advantage
Database Management✅ Full management✅ Full managementEquivalent
Zero Dependencies✅ Yes❌ 50+ packagesCosmosQL advantage
Query Builder✅ Type-safe❌ SQL stringsCosmosQL advantage

When to Use CosmosQL vs Azure SDK

Use CosmosQL when:

  • ✅ You prioritize type safety and developer experience
  • ✅ You need zero dependencies for serverless/microservices
  • ✅ Your use cases fit core CRUD operations
  • ✅ You want compile-time guarantees for partition keys
  • ✅ You're building new TypeScript applications
  • ✅ You need database and container management (with auto-create, verify, or skip modes)

Use Azure SDK when:

  • ✅ You need advanced features (change feed, stored procedures)
  • ✅ You need cross-partition transactions
  • ✅ You're working with JavaScript (not TypeScript)
  • ✅ You need geospatial queries or full-text search

Use Both:

You can use CosmosQL alongside the Azure SDK for different use cases within the same project. They don't interfere with each other and work with the same database.

Roadmap

High Priority:

  1. ✅ Core CRUD operations (Completed)
  2. ✅ Type-safe query builder (Completed)
  3. ✅ Aggregation functions (Completed)
  4. 🔄 Cursor pagination (Planned)
  5. 🔄 Batch operations (Planned)

Medium Priority:

  1. ✅ Container management APIs (Completed)
  2. ⏳ Advanced querying (Geospatial, full-text) (Future)
  3. ⏳ Performance monitoring (Future)
  4. ⏳ Migration support (Future)

Lower Priority:

  1. ⏳ Multiple database support (Future)
  2. ⏳ Advanced connection pooling (Future)
  3. ⏳ Circuit breaker patterns (Future)

Reporting Issues

If you encounter limitations that impact your use case:

  1. Check existing issues on GitHub
  2. Create a feature request with use case details
  3. Contribute - Pull requests welcome!
  4. Use workarounds - Many limitations have documented workarounds

Getting Help

For questions about limitations or missing features:

  • Check the FAQ for common questions
  • Review Common Patterns for workarounds
  • Open a GitHub issue for specific feature requests
  • Join the community Discord for discussions

Migration

Learn how to migrate from the official Azure CosmosDB SDK to CosmosQL

View on separate page →

Migration from Azure SDK

If you're currently using the official Azure CosmosDB SDK (@azure/cosmos), this guide will help you migrate to CosmosQL. The migration is straightforward—CosmosQL works with existing databases and data, so you can adopt it incrementally.

Why Migrate?

The Azure SDK is powerful, but CosmosQL provides significant advantages:

Key Benefits

  • Type Safety: Compile-time guarantees instead of runtime errors
  • Better Developer Experience: Cleaner API, autocomplete, fewer mistakes
  • Performance: Zero runtime overhead, direct REST API calls
  • Cost Prevention: Type system prevents expensive cross-partition queries
  • Minimal Dependencies: No SDK bloat (50+ packages → zero dependencies)

Migration Strategy

You don't need to migrate everything at once. Here's a recommended approach:

Incremental Migration

CosmosQL and the Azure SDK can coexist in the same project—they use the same database and don't interfere. This allows you to migrate gradually without disruption.

  1. Start with New Code: Use CosmosQL for new features and endpoints
  2. Migrate High-Traffic Paths: Convert frequently-used operations to CosmosQL
  3. Gradual Replacement: Migrate remaining code incrementally
  4. Remove SDK: Once everything is migrated, remove the Azure SDK dependency

Basic Migration Examples

Creating a Client

Before (Azure SDK):

import { CosmosClient } from '@azure/cosmos';

const client = new CosmosClient(connectionString);
const database = client.database('mydb');
const container = database.container('users');

After (CosmosQL):

import { createClient, container, field } from 'cosmosql';

// Define your schema
const users = container('users', {
  id: field.string(),
  email: field.string(),
  name: field.string()
}).partitionKey('email');

// Create typed client (async - validates/creates containers)
const db = await createClient({
  connectionString,
  database: 'mydb',
  mode: 'auto-create'
}).withContainers({ users });

Key Differences:

Important Changes

  • CosmosQL requires schema definitions (for type safety)
  • Client creation is a one-time setup per database
  • db.users is fully typed, container is not

Querying Documents

Before (Azure SDK):

const { resources } = await container.items
  .query({
    query: 'SELECT * FROM c WHERE c.id = @id AND c.email = @email',
    parameters: [
      { name: '@id', value: 'user_123' },
      { name: '@email', value: 'john@example.com' }
    ]
  })
  .fetchAll();

const user = resources[0]; // No type safety
// Type: any - could be undefined, wrong shape, etc.

After (CosmosQL):

// Point read (most efficient)
const user = await db.users.findUnique({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  }
});

// Type: { id: string; email: string; name: string } | null
// TypeScript knows the exact shape and nullability

Benefits:

  • Type-safe: TypeScript knows the exact return type
  • Simpler: No SQL strings, no parameter arrays
  • Safer: Can't forget partition key (required by types)
  • More efficient: findUnique uses point reads (faster than queries)

Querying Multiple Documents

Before (Azure SDK):

const { resources } = await container.items
  .query({
    query: 'SELECT * FROM c WHERE c.isActive = @active ORDER BY c.createdAt DESC',
    parameters: [{ name: '@active', value: true }]
  })
  .fetchAll();

// No type safety, no partition key enforcement

After (CosmosQL):

// Partition-scoped query (efficient)
const activeUsers = await db.users.findMany({
  partitionKey: 'john@example.com', // Required!
  where: {
    isActive: true
  },
  orderBy: {
    createdAt: 'desc'
  }
});

// Type: Array<{ id: string; email: string; ... }>
// Partition key is enforced at compile time

Creating Documents

Before (Azure SDK):

const { resource: newUser } = await container.items.create({
  id: 'user_123',
  email: 'john@example.com',
  name: 'John Doe'
});

// No type checking - could have typos, missing fields, etc.

After (CosmosQL):

const newUser = await db.users.create({
  data: {
    id: 'user_123',
    email: 'john@example.com',
    name: 'John Doe'
    // TypeScript ensures all required fields are present
    // Autocomplete helps you discover available fields
  }
});

// Type: { id: string; email: string; name: string }
// Fully typed result

Updating Documents

Before (Azure SDK):

const { resource: updated } = await container
  .item('user_123', 'john@example.com') // ID and partition key
  .replace({
    id: 'user_123',
    email: 'john@example.com',
    name: 'John Updated',
    age: 30 // Could forget fields, wrong types, etc.
  });

After (CosmosQL):

// Update specific fields (partial update)
const updated = await db.users.update({
  where: {
    id: 'user_123',
    email: 'john@example.com'
  },
  data: {
    name: 'John Updated',
    age: 30
    // Only include fields you want to update
    // TypeScript ensures types match schema
  }
});

// Type: { id: string; email: string; name: string; age: number }

Deleting Documents

Before (Azure SDK):

await container
  .item('user_123', 'john@example.com')
  .delete();

After (CosmosQL):

await db.users.delete({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  }
});

// Type-safe: can't delete without partition key

Handling Common Patterns

Error Handling

Before (Azure SDK):

try {
  await container.items.create(user);
} catch (error: any) {
  if (error.code === 409) {
    // Conflict
  } else if (error.code === 429) {
    // Rate limited
  }
  // Error types are loose, easy to miss cases
}

After (CosmosQL):

import { CosmosError } from 'cosmosql';

try {
  await db.users.create({ data: user });
} catch (error) {
  if (error instanceof CosmosError) {
    switch (error.code) {
      case 409: // Conflict
      case 429: // Rate limited
      // Type-safe error handling
    }
  }
}

Cross-Partition Queries

Before (Azure SDK):

// Easy to accidentally do expensive cross-partition query
const { resources } = await container.items
  .query('SELECT * FROM c WHERE c.isActive = true')
  .fetchAll();
// This scans ALL partitions - expensive!

After (CosmosQL):

// Type system prevents accidental cross-partition queries
const users = await db.users.findMany({
  enableCrossPartitionQuery: true, // Must explicitly opt in
  where: { isActive: true }
});

// You're forced to think about the cost

Migration Checklist

When migrating code from Azure SDK to CosmosQL:

  • Define schemas for all containers you use
  • Identify partition keys for each container
  • Replace client creation with CosmosQL client
  • Convert queries from SQL strings to query builder
  • Update error handling to use CosmosError
  • Test thoroughly in staging before production
  • Monitor RU usage to ensure queries are efficient
  • Update types throughout your codebase
  • Remove Azure SDK once migration is complete

Common Pitfalls

Forgetting Partition Keys: CosmosQL requires partition keys in where clauses. This is intentional—it prevents expensive cross-partition queries. Make sure you always provide partition keys.

Schema Mismatches: Your CosmosQL schema must match your actual document structure. Review existing documents to ensure field names and types align.

Type Assertions: Avoid using as any to bypass type checking. If you need to, it's often a sign your schema needs adjustment.

Gradual Migration: Don't try to migrate everything at once. Start small, test thoroughly, then expand.

Getting Help

If you run into issues during migration:

  1. Check the FAQ for common questions
  2. Review the API Reference for method details
  3. Look at Common Patterns for real-world examples
  4. Open a GitHub issue if you find bugs or need clarification

The migration process is straightforward, and you'll quickly see the benefits of type safety and better developer experience.

Performance

Optimize your CosmosQL queries for speed and cost efficiency

View on separate page →

Performance Best Practices

1. Always Use Partition Keys When Possible

The Cost Difference

// ❌ BAD: Cross-partition query (expensive)
const user = await db.users.findMany({
  enableCrossPartitionQuery: true,
  where: { age: { gt: 18 } }
});
// Cost: ~50-100 RU

// ✅ GOOD: Partition-scoped query
const user = await db.users.findMany({
  partitionKey: 'john@example.com',
  where: { age: { gt: 18 } }
});
// Cost: ~5 RU (10-20x cheaper)

2. Use Point Reads for Single Documents

Performance Tip

// ❌ OK: Query for single document
const user = await db.users.findMany({
  partitionKey: 'john@example.com',
  where: { id: 'user_123' },
  take: 1
});
// Cost: ~3-5 RU

// ✅ BEST: Point read (direct lookup)
const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' }
});
// Cost: ~1 RU (5x cheaper)

3. Select Only Needed Fields

// ❌ BAD: Fetch entire document
const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' }
});
// Returns all fields

// ✅ GOOD: Fetch only what you need
const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' },
  select: { name: true, email: true }
});
// Smaller payload = faster transfer = lower latency

4. Batch Operations in Same Partition

Batch Efficiency

// ❌ BAD: Individual creates (multiple requests)
for (const post of posts) {
  await db.posts.create({ data: post });
}
// 10 posts = 10 HTTP requests

// ✅ GOOD: Batch create (single request)
await db.posts.createMany({
  data: posts,
  partitionKey: 'user_123'
});
// 10 posts = 1 HTTP request (10x faster)

5. Use Appropriate Indexes

CosmosDB automatically indexes all properties by default, but you can optimize:

const users = container('users', {
  id: field.string(),
  email: field.string(),
  name: field.string(),
  metadata: field.object({ /* large nested object */ })
}).partitionKey('email')
  .indexingPolicy({
    automatic: true,
    includedPaths: [
      '/id/?',
      '/email/?',
      '/name/?'
    ],
    excludedPaths: [
      '/metadata/*' // Don't index large nested objects
    ]
  });

Core Concepts

Understand CosmosDB fundamentals, partition keys, and how CosmosQL prevents costly mistakes

View on separate page →

Why this matters: CosmosDB has unique characteristics that shape how you query data. Understanding these fundamentals helps you write efficient, cost-effective code.

Skip this if: You're already a CosmosDB expert. Jump to Creating Documents.

Read this if: CosmosDB is new, or you want to understand the "why" behind CosmosQL's design.


The Distributed Database Problem

CosmosDB isn't one server—it's thousands of servers working together. Your data is split across them.

This creates a challenge: How do you query data when it's spread across hundreds of machines?

The solution: Partition keys.

Partition Keys: The $2,400 Question

Every document in CosmosDB has a partition key. This value determines which server stores it.

Why This Matters (Real Scenario)

A developer wrote this query:

// Find all active users
const users = await findWhere({ isActive: true });

With 1,000 users: Cost $5/month, worked fine.
With 100,000 users: Cost $2,400/month, still worked fine.

What went wrong? No partition key = CosmosDB scans every server.

The Cost Breakdown

Query TypeServers ScannedRequest UnitsMonthly Cost (1M queries)
With partition key11 RU$24
Without partition keyAll (100+)100 RU$2,400

100x difference.

How CosmosQL Prevents This

// ❌ TypeScript won't compile this
const users = await db.users.findMany({
  where: { isActive: true }
  // Error: Missing required property 'partitionKey'
});

// ✅ Forces you to be explicit
const users = await db.users.findMany({
  partitionKey: 'user@example.com', // Scans one partition
  where: { isActive: true }
});

// ✅ Or opt-in to cross-partition (expensive) queries
const users = await db.users.findMany({
  enableCrossPartitionQuery: true, // "I know this is expensive"
  where: { isActive: true }
});

You cannot accidentally write expensive queries. TypeScript won't let your code compile.

Choosing a Good Partition Key

Good partition keys have three properties:

1. High Cardinality (Many Unique Values)

// ✅ Good: email addresses
const users = container('users', schema).partitionKey('email');

// ❌ Bad: boolean status
const users = container('users', schema).partitionKey('isActive');
// Only 2 values → only 2 partitions → poor distribution

2. Even Distribution

// ✅ Good: user IDs spread evenly
const posts = container('posts', schema).partitionKey('userId');

// ⚠️ Risky: category (one category could be huge)
const products = container('products', schema).partitionKey('category');
// "Electronics" might be 80% of your data → hot partition

3. Query Alignment

Choose keys that match your most common query patterns:

// If you always query by user
const posts = container('posts', schema).partitionKey('userId'); // ✅

// If you always query by date
const metrics = container('metrics', schema).partitionKey('timestamp'); // ✅

// If you query by multiple fields
const orders = container('orders', schema).partitionKey('customerId'); // ✅

Request Units (RU): How CosmosDB Charges

CosmosDB uses Request Units (RUs) to measure and charge for operations.

Typical Costs

OperationCostNotes
Point read1 RUReading by ID + partition key
Partition query3-5 RUFiltering within one partition
Cross-partition query50-100+ RUScanning all partitions
Write5-10 RUCreating or updating

Real Cost Example

If you process 1 million queries per month:

// Partition-scoped queries
1M queries × 3 RU = 3M RU/month ≈ $72/month

// Cross-partition queries  
1M queries × 100 RU = 100M RU/month ≈ $2,400/month

That's 33x more expensive.

Schema Definition

Schemas in CosmosQL are TypeScript-only - they don't exist at runtime.

import { container, field } from 'cosmosql';

const posts = container('posts', {
  // Required fields
  id: field.string(),
  userId: field.string(),
  title: field.string(),
  
  // Optional fields
  subtitle: field.string().optional(),
  
  // Defaults
  viewCount: field.number().default(0),
  isPublished: field.boolean().default(false),
  
  // Arrays
  tags: field.array(field.string()),
  
  // Nested objects
  metadata: field.object({
    source: field.string(),
    version: field.number()
  }).optional(),
  
  // TTL (auto-delete after N seconds)
  ttl: field.number().optional()
}).partitionKey('userId');

// Get TypeScript type
type Post = typeof posts.infer;

Key point: This is purely for TypeScript. CosmosDB is schemaless - CosmosQL just adds type safety.

Field Types

  • field.string()string
  • field.number()number
  • field.boolean()boolean
  • field.date()Date
  • field.array(type)Array<type>
  • field.object(schema) → nested object

Modifiers

  • .optional() → field can be undefined
  • .default(value) → value used if not provided on create

Client Setup

import { createClient } from 'cosmosql';

const db = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'myapp',
  mode: 'auto-create' // Validates and creates containers
}).withContainers({
  users,
  posts,
  comments
});

// Now you have typed access
db.users     // ContainerClient<User>
db.posts     // ContainerClient<Post>
db.comments  // ContainerClient<Comment>

Client creation is async because it validates and optionally creates your database and containers. Choose a mode based on your environment:

  • auto-create - Development: creates missing containers automatically
  • verify - Production: fails fast if containers don't exist
  • skip - Maximum performance: no validation

See Getting Started Guide for detailed mode documentation.

Connection management: HTTP/2 pooling with automatic retries. You don't manage connections.

Multiple databases: Create separate clients:

const prodDb = await createClient({...}).withContainers({users});
const analyticsDb = await createClient({...}).withContainers({events});

Summary

Three key takeaways:

  1. Partition keys determine cost - Queries without them cost 10-100x more
  2. CosmosQL enforces them at compile time - TypeScript prevents expensive mistakes
  3. Schemas are TypeScript-only - Zero runtime overhead, all compile-time checks

Composite Partition Keys

When you need multi-tenant data or hierarchical structures:

const documents = container('documents', {
  id: field.string(),
  tenantId: field.string(),
  organizationId: field.string(),
  name: field.string(),
  content: field.string()
}).partitionKey('tenantId', 'organizationId'); // Composite key

// Querying requires both parts
const docs = await db.documents.findMany({
  partitionKey: ['tenant_1', 'org_a'], // Array for composite keys
  where: { name: { startsWith: 'Report' } }
});

Summary

Three key takeaways:

  1. Partition keys determine cost - Queries without them cost 10-100x more
  2. CosmosQL enforces them at compile time - TypeScript prevents expensive mistakes
  3. Schemas are TypeScript-only - Zero runtime overhead, all compile-time checks

Next: Apply these concepts in Creating Documents.

Quickstart

Get up and running with CosmosQL in 5 minutes

View on separate page →

Time: 10 minutes
You'll learn: Installation → First query → How it works → Customization

Prerequisites

Note: CosmosQL is optimized for Bun runtime but works perfectly with Node.js. Both runtimes are fully supported.

Install

npm install cosmosql

CosmosQL has zero runtime dependencies beyond Node.js.

Your First Query

We're building: A simple user authentication system.

Copy and run this:

import { createClient, container, field } from 'cosmosql';
import dotenv from 'dotenv';

dotenv.config();

// Step 1: Define your schema
const users = container('users', {
  id: field.string(),
  email: field.string(),
  name: field.string(),
  age: field.number(),
  isActive: field.boolean().default(true),
  createdAt: field.date()
}).partitionKey('email');

// Step 2: Create a client (async - validates and creates containers)
const db = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'myapp',
  mode: 'auto-create' // Creates database/containers if missing (dev default)
}).withContainers({ users });

// Step 3: Create a user
async function main() {
  const newUser = await db.users.create({
    data: {
      id: 'user_123',
      email: 'john@example.com',
      name: 'John Doe',
      age: 30,
      createdAt: new Date()
      // isActive automatically true (default)
    }
  });
  console.log('Created user:', newUser.name);
  
  // Step 4: Query by ID + partition key (1 RU - cheapest)
  const user = await db.users.findUnique({
    where: {
      id: 'user_123',
      email: 'john@example.com' // Partition key required!
    }
  });
  console.log('Found user:', user?.name);
}

main().catch(console.error);

Run it: npx tsx example.ts

Expected output:

Created user: John Doe
Found user: John Doe

What Just Happened?

Let's break down each part:

1. Schema Definition

const users = container('users', {
  id: field.string(),
  email: field.string(),
  // ...
}).partitionKey('email');

This defines your data structure. Three key points:

  • Not runtime validation - This is purely for TypeScript
  • Partition key is required - More on why in Core Concepts
  • Inference happens automatically - TypeScript knows the shape

2. Client Creation

const db = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'myapp',
  mode: 'auto-create' // 'auto-create' | 'verify' | 'skip'
}).withContainers({ users });

What happens: Client creation is async because it validates and optionally creates your database and containers.

Three modes:

  • auto-create (development default) - Creates database/containers if missing, updates indexing policies. Slower startup (~500ms-2s) but zero friction.
  • verify (production default) - Checks everything exists, throws if misconfigured. Fast (~100-300ms), fail-fast on errors.
  • skip (maximum performance) - No checks, instant. Use when infrastructure is guaranteed (e.g., after CI/CD deploy).

The client manages connections and provides typed access. After withContainers, db.users is fully typed.

3. Type-Safe Create

const newUser = await db.users.create({ data: { ... } });

TypeScript enforces:

  • All required fields are provided
  • Field types match your schema
  • Default values are applied automatically

4. Point Read (Fastest Query)

const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' }
});

This costs ~1 RU (Request Unit) - the cheapest possible query. TypeScript requires both ID and the partition key (email).

Make It Yours

Change the example to match your data:

1. Update the schema: Replace fields with yours

const posts = container('posts', {
  id: field.string(),
  userId: field.string(),
  title: field.string(),
  content: field.string(),
  publishedAt: field.date().optional()
}).partitionKey('userId');

2. Change the partition key: See Choosing Partition Keys for guidance

3. Add your connection string: Store in .env file

COSMOS_CONNECTION_STRING="AccountEndpoint=https://..."

Complete working example:

import { createClient, container, field } from 'cosmosql';
import dotenv from 'dotenv';

dotenv.config();

const posts = container('posts', {
  id: field.string(),
  userId: field.string(),
  title: field.string(),
  content: field.string(),
  publishedAt: field.date().optional()
}).partitionKey('userId');

const db = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'myapp',
  mode: process.env.NODE_ENV === 'production' ? 'verify' : 'auto-create'
}).withContainers({ posts });

async function main() {
  // Create a post
  const post = await db.posts.create({
    data: {
      id: 'post_123',
      userId: 'user_456',
      title: 'Hello CosmosQL',
      content: 'This is my first post',
      publishedAt: new Date()
    }
  });
  console.log('Created:', post.title);
  
  // Query posts for a user
  const userPosts = await db.posts.findMany({
    partitionKey: 'user_456',
    where: { publishedAt: { ne: null } }
  });
  console.log(`Found ${userPosts.length} published posts`);
}

main().catch(console.error);

Schema Configuration

You can configure container settings like throughput and indexing policies:

const users = container('users', {
  id: field.string(),
  email: field.string(),
  name: field.string()
})
  .partitionKey('email')
  .throughput(400) // Optional: Set RU/s (auto-scale also available)
  .indexing({       // Optional: Configure indexing policy
    automatic: true,
    excludePaths: ['/largeField/?'] // Exclude large fields from indexing
  });

const db = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'myapp',
  mode: 'auto-create' // Updates indexing in auto-create mode
}).withContainers({ users });

Important: In auto-create mode, indexing policies are updated to match your schema. In verify mode, mismatches trigger warnings but containers aren't modified (production safety).

Partition Key Mismatches

If your schema defines a partition key that doesn't match an existing container, CosmosQL throws an error:

// Schema says: partitionKey('email')
// Existing container has: partitionKey('id')
// Throws: "Partition key mismatch: expected '/email', found '/id'"
//         "Cannot modify partition key. Delete container or use different name."

Solution: Delete the container manually (via Azure Portal/CLI) or use a different container name in your schema.

What You Learned

  • ✓ Schemas define structure for TypeScript (not runtime)
  • ✓ Partition keys are required and enforced
  • ✓ Queries are fully typed automatically
  • ✓ Point reads are cheapest (~1 RU vs ~5 RU for queries)

Next Steps

New to CosmosDB? → Read Core Concepts to understand partition keys and Request Units

Ready to build? → See Creating Documents for all operations

Need patterns? → Check Patterns & Use Cases for real scenarios

Getting Your Connection String

  1. Open Azure Portal
  2. Navigate to your CosmosDB account
  3. Go to Keys in the left sidebar
  4. Copy the Primary Connection String

The format looks like:

AccountEndpoint=https://your-account.documents.azure.com:443/;AccountKey=your-key==;

Important: Never commit your connection string to version control. Always use environment variables or a secrets manager.

Advanced Patterns

Working with partition keys, TTL, raw queries, and error handling

View on separate page →

Working with Partition Keys

Understanding the Trade-offs:

Cost Comparison

// ✅ GOOD: Query with partition key (1-5 RU)
const posts = await db.posts.findMany({
  partitionKey: 'user_123',
  where: { isPublished: true }
});

// ⚠️ EXPENSIVE: Cross-partition query (50-100+ RU)
const posts = await db.posts.findMany({
  enableCrossPartitionQuery: true,
  where: { isPublished: true }
});

The cost difference is 10-100x higher for cross-partition queries.

Choosing a Good Partition Key:

Good Partition Keys Have

  • High cardinality - Many unique values (e.g., userId, email)
  • Even distribution - Data spread evenly across values
  • Query alignment - Most queries filter by this field
// ✅ Good: User emails (high cardinality, even distribution)
const users = container('users', schema).partitionKey('email');

// ✅ Good: UserId for user-specific data
const posts = container('posts', schema).partitionKey('userId');

// ❌ Bad: Boolean field (only 2 values, uneven distribution)
const users = container('users', schema).partitionKey('isActive');

// ❌ Bad: Status with few values
const orders = container('orders', schema).partitionKey('status');

Composite Partition Keys

When you need multi-tenant data or hierarchical structures:

const documents = container('documents', {
  id: field.string(),
  tenantId: field.string(),
  organizationId: field.string(),
  name: field.string(),
  content: field.string()
}).partitionKey('tenantId', 'organizationId'); // Composite key

// Querying requires both parts
const docs = await db.documents.findMany({
  partitionKey: ['tenant_1', 'org_a'], // Array for composite keys
  where: { name: { startsWith: 'Report' } }
});

TTL (Time-To-Live)

Auto-expire documents after a period:

const sessions = container('sessions', {
  id: field.string(),
  userId: field.string(),
  token: field.string(),
  createdAt: field.date(),
  ttl: field.number() // Seconds until auto-delete
}).partitionKey('userId');

// Create session that expires in 1 hour
await db.sessions.create({
  data: {
    id: 'session_123',
    userId: 'user_123',
    token: 'abc...',
    createdAt: new Date(),
    ttl: 3600 // 1 hour in seconds
  }
});

// CosmosDB automatically deletes after 1 hour

Raw Queries

For advanced use cases beyond the query builder:

const result = await db.users.query<{ name: string; age: number }>({
  sql: `
    SELECT c.name, c.age 
    FROM c 
    WHERE c.age > @minAge 
    AND c.isActive = @active
  `,
  parameters: [
    { name: '@minAge', value: 18 },
    { name: '@active', value: true }
  ],
  partitionKey: 'john@example.com' // Optional: for partition-scoped query
});

// Type: Array<{ name: string; age: number }>

When to Use Raw Queries:

  • Complex aggregations

  • CosmosDB-specific functions (e.g., spatial queries)

  • Performance optimization with custom SQL

  • Features not yet supported by query builder

Error Handling

CosmosQL provides comprehensive error handling through the CosmosError class:

import { CosmosError, isCosmosError } from 'cosmosql';

try {
  await db.users.create({ data: user });
} catch (error) {
  if (isCosmosError(error)) {
    // Type-safe error handling
    switch (error.statusCode) {
      case 400: // Bad Request
        console.error('Invalid request:', error.message);
        break;
      case 401: // Unauthorized
        console.error('Authentication failed - check your connection string');
        break;
      case 403: // Forbidden
        console.error('Access denied - check permissions');
        break;
      case 404: // Not Found
        console.error('Container or database not found');
        break;
      case 409: // Conflict (duplicate ID)
        console.error('Document already exists with this ID');
        break;
      case 412: // Precondition Failed (ETag mismatch)
        console.error('Document was modified, please retry');
        break;
      case 429: // Too Many Requests (rate limit)
        console.error('Rate limited, retry after:', error.retryAfter, 'seconds');
        // CosmosError includes retryAfter property
        break;
      case 500: // Internal Server Error
        console.error('CosmosDB service error:', error.message);
        break;
      default:
        console.error('CosmosDB error:', error.code, error.message);
    }
  } else {
    // Not a CosmosDB error, rethrow
    throw error;
  }
}

CosmosError Class:

class CosmosError extends Error {
  statusCode: number;      // HTTP status code (400, 404, 429, etc.)
  code: string;            // CosmosDB error code
  message: string;         // Error message
  retryAfter?: number;     // Seconds to wait before retry (for 429 errors)
}

Common Error Codes:

CodeStatusDescriptionCommon Causes
BadRequest400Invalid requestMalformed query syntax, invalid parameters
Unauthorized401Authentication failedInvalid connection string or key
Forbidden403Access deniedInsufficient permissions
NotFound404Resource not foundContainer/database doesn't exist, document not found
Conflict409Duplicate resourceDocument with same ID already exists
PreconditionFailed412ETag mismatchDocument was modified (optimistic concurrency)
TooManyRequests429Rate limitedRU/s limit exceeded, automatic retry available
InternalServerError500Service errorCosmosDB service issue
CROSS_PARTITION_QUERY_ERROR-Cross-partition query errorEmpty container cross-partition query

Common Error Scenarios:

  • 404: Document not found (returns null for findUnique/findMany, throws for update/delete)
  • 429: Rate limited (automatic retry with backoff if configured via retryOptions)
  • 401/403: Authentication/authorization failures (check connection string and keys)
  • 400: Bad request (validation errors, invalid query syntax)
  • 409: Conflict (duplicate ID on create - indicates business logic issue)
  • 412: Precondition failed (ETag mismatch on updates - document was modified)
  • CROSS_PARTITION_QUERY_ERROR: Cross-partition queries on empty containers (CosmosDB limitation)

Auto-Retry on 429:

CosmosQL automatically retries rate-limited requests with exponential backoff:

const db = await createClient({
  connectionString: '...',
  database: 'myapp',
  mode: 'verify', // Production: fail-fast on misconfiguration
  retryOptions: {
    maxRetries: 3,        // Maximum retry attempts
    initialDelay: 100,    // Initial delay in ms
    maxDelay: 5000        // Maximum delay in ms
  }
}).withContainers({ users });

// Automatically retries with exponential backoff on 429 errors
// Uses retryAfter from CosmosDB response when available

Error Handling Best Practices:

  1. Always check error types - Use isCosmosError() for type-safe error handling
  2. Handle rate limits gracefully - Use built-in retry options or implement custom retry logic
  3. Log error details - Include statusCode, code, and message in error logs for debugging
  4. Don't ignore conflicts - 409 errors indicate business logic issues (duplicate IDs)
  5. Handle ETag mismatches - 412 errors mean document was modified; refetch and retry
  6. Check for cross-partition query errors - Ensure containers have data before cross-partition queries

Aggregations

Complete guide to using aggregation functions in CosmosQL - count, sum, average, min, max, and group by operations with full type safety.

View on separate page →

Complete guide to using aggregation functions in CosmosQL - count, sum, average, min, max, and group by operations with full type safety.

Table of Contents


Overview

CosmosQL provides a complete set of aggregation functions that are:

  • Fully type-safe - TypeScript infers exact result types
  • SQL-compatible - Translates to efficient Cosmos DB SQL
  • Partition-aware - Optimized for Cosmos DB's partitioning
  • Composable - Combine multiple aggregations in one query

Available Aggregations

OperationDescriptionWorks With
_countCount documentsAll fields
_sumSum valuesNumber fields
_avgAverage valuesNumber fields
_minFind minimumAll fields
_maxFind maximumAll fields

Count Operations

Basic Count

Count documents in a partition:

import { createClient, container, field } from 'cosmosql';

const orderSchema = container('orders', {
  id: field.string(),
  customerId: field.string(),
  amount: field.number(),
  status: field.string(),
}).partitionKey('customerId');

const client = createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'mydb',
});

const db = await client.withContainers({ orders: orderSchema });

// Count orders for a customer
const count = await db.orders.count({
  partitionKey: 'customer-123',
});
// Result: 42

Count with Filters

Add WHERE conditions to count specific documents:

// Count only completed orders
const completedCount = await db.orders.count({
  partitionKey: 'customer-123',
  where: {
    status: 'completed',
  },
});

// Count with complex filters
const recentHighValueCount = await db.orders.count({
  partitionKey: 'customer-123',
  where: {
    status: 'completed',
    amount: { gte: 100 },
    createdAt: { gte: new Date('2024-01-01') },
  },
});

Cross-Partition Count

⚠️ Use sparingly - Cross-partition queries are expensive in Cosmos DB.

// Count ALL orders across all customers
const totalOrders = await db.orders.count({
  enableCrossPartitionQuery: true,
});

// Cross-partition count with filter
const allCompletedOrders = await db.orders.count({
  enableCrossPartitionQuery: true,
  where: { status: 'completed' },
});

Count Specific Fields

Count non-null values in specific fields:

const result = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _count: {
    select: {
      amount: true,        // Count orders with amount
      discount: true,      // Count orders with discount (might be optional)
      trackingNumber: true,
    },
  },
});

// Result type is inferred:
// {
//   _count: {
//     amount: number;
//     discount: number;
//     trackingNumber: number;
//   }
// }

Aggregate Operations

Single Aggregation

// Sum all order amounts for a customer
const result = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _sum: {
    amount: true,
  },
});

// Result: { _sum: { amount: 1250.50 | null } }

Multiple Aggregations

Combine multiple operations in one query (more efficient than separate queries):

const stats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _count: true,                    // Total count
  _sum: { amount: true },          // Total revenue
  _avg: { amount: true },          // Average order value
  _min: { amount: true },          // Smallest order
  _max: { amount: true },          // Largest order
});

// Result type (fully inferred):
// {
//   _count: number;
//   _sum: { amount: number | null };
//   _avg: { amount: number | null };
//   _min: { amount: number | null };
//   _max: { amount: number | null };
// }

console.log(`
  Orders: ${stats._count}
  Total Revenue: $${stats._sum.amount}
  Average Order: $${stats._avg.amount}
  Range: $${stats._min.amount} - $${stats._max.amount}
`);

Aggregations with Filters

// Stats for completed orders only
const completedStats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  where: { status: 'completed' },
  _count: true,
  _sum: { amount: true },
  _avg: { amount: true },
});

// Year-to-date statistics
const ytdStats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  where: {
    createdAt: { gte: new Date('2024-01-01') },
  },
  _sum: { amount: true, tax: true, shipping: true },
  _count: true,
});

Multiple Field Aggregations

const productSchema = container('products', {
  id: field.string(),
  category: field.string(),
  price: field.number(),
  cost: field.number(),
  stock: field.number(),
  weight: field.number(),
}).partitionKey('category');

const stats = await db.products.aggregate({
  partitionKey: 'electronics',
  _sum: {
    stock: true,    // Total inventory
    price: true,    // Total value at retail
    cost: true,     // Total cost basis
  },
  _avg: {
    price: true,    // Average price
    weight: true,   // Average weight
  },
  _min: {
    price: true,    // Cheapest product
  },
  _max: {
    price: true,    // Most expensive product
  },
});

// Calculate profit margin
const totalProfit = stats._sum.price! - stats._sum.cost!;
const margin = (totalProfit / stats._sum.price!) * 100;
console.log(`Profit margin: ${margin.toFixed(2)}%`);

findMany with Aggregations

The findMany method supports aggregations, allowing you to fetch data and calculate statistics in a single, efficient query. This combines the power of data retrieval with aggregation operations.

Return Types

Without Aggregations (Backward Compatible):

// No aggregate parameter
const users = await db.users.findMany({ partitionKey: "john@example.com" });
// Type: User[]

const selectedUsers = await db.users.findMany({ 
  partitionKey: "john@example.com",
  select: { id: true, name: true }
});
// Type: Array<{ id: string, name: string }>

With Aggregations:

// With aggregate parameter
const result = await db.users.findMany({
  partitionKey: "john@example.com",
  aggregate: { _count: true }
});
// Type: { data: User[], _count: number }

Basic Usage

const result = await db.users.findMany({
  partitionKey: "john@example.com",
  where: { isActive: true },
  aggregate: {
    _count: true,
    _avg: { age: true }
  }
});

console.log(`Found ${result._count} active users`);
console.log(`Average age: ${result._avg.age}`);
console.log(`Users:`, result.data);

Multiple Aggregations

Combine data retrieval with comprehensive statistics:

const stats = await db.orders.findMany({
  partitionKey: "customer-123",
  aggregate: {
    _count: true,
    _sum: { totalAmount: true },
    _avg: { totalAmount: true },
    _min: { createdAt: true },
    _max: { createdAt: true }
  }
});

console.log(`Order Statistics:`);
console.log(`  Total orders: ${stats._count}`);
console.log(`  Total revenue: ${stats._sum.totalAmount}`);
console.log(`  Average order: ${stats._avg.totalAmount}`);
console.log(`  First order: ${stats._min.createdAt}`);
console.log(`  Latest order: ${stats._max.createdAt}`);
console.log(`  Orders: ${stats.data.length} items`);

With Filtering and Selection

const result = await db.products.findMany({
  partitionKey: "category-electronics",
  where: { 
    price: { gte: 100 },
    inStock: true 
  },
  select: { id: true, name: true, price: true },
  aggregate: {
    _count: true,
    _avg: { price: true },
    _min: { price: true },
    _max: { price: true }
  }
});

// Only selected fields in data
console.log(result.data); // Array<{ id: string, name: string, price: number }>

// All aggregations available
console.log(`Expensive products: ${result._count}`);
console.log(`Price range: ${result._min.price} - ${result._max.price}`);

Paginated Results with Total Count

Perfect for building paginated UIs with accurate page counts:

const result = await db.users.findMany({
  partitionKey: "john@example.com",
  take: 10,  // Get first 10 users
  skip: 0,
  aggregate: {
    _count: true,  // Count ALL users matching query
  }
});

console.log(`Showing ${result.data.length} of ${result._count} total users`);

// Calculate total pages
const totalPages = Math.ceil(result._count / 10);
console.log(`Page 1 of ${totalPages}`);

Cross-Partition Queries

const result = await db.products.findMany({
  where: { category: "electronics" },
  enableCrossPartitionQuery: true,
  aggregate: {
    _count: true,
    _avg: { price: true }
  }
});

console.log(`Electronics across all partitions:`);
console.log(`  Total products: ${result._count}`);
console.log(`  Average price: ${result._avg.price}`);

Behavior & Performance

Parallel Execution:

  • Data query and aggregation query execute in parallel
  • Both queries use the same where, partitionKey, and enableCrossPartitionQuery filters
  • Results are combined into a single response object

Filtering Rules:

  • where clause: Applied to both data and aggregation queries
  • orderBy, take, skip: Applied only to data query (aggregations count all matching records)
  • select: Applied only to data query (aggregations work on full records)

Comparison with Separate Methods

// Before: Two separate queries
const users = await db.users.findMany({ partitionKey: "john@example.com" });
const stats = await db.users.aggregate({
  partitionKey: "john@example.com",
  _count: true,
  _avg: { age: true }
});

// After: Single efficient query
const result = await db.users.findMany({
  partitionKey: "john@example.com",
  aggregate: {
    _count: true,
    _avg: { age: true }
  }
});
// result.data contains users
// result._count and result._avg contain aggregations

When to Use Each Approach:

Use findMany with aggregate:

  • When you need both data and aggregations
  • For paginated results with total counts
  • When filtering is shared between data and aggregations

Use separate aggregate() method:

  • When you only need aggregations (no data)
  • For complex group-by operations
  • When data and aggregations have different filters

All Aggregation Operations

Count Records:

const result = await db.users.findMany({
  partitionKey: "john@example.com",
  aggregate: { _count: true }
});
// result._count: number

// Count specific fields (non-null values)
const result = await db.users.findMany({
  partitionKey: "john@example.com",
  aggregate: {
    _count: { select: { name: true, email: true } }
  }
});
// result._count: { name: number, email: number }

Sum, Average, Min, Max:

const result = await db.orders.findMany({
  partitionKey: "customer-123",
  aggregate: {
    _sum: { amount: true, tax: true },
    _avg: { amount: true },
    _min: { amount: true, createdAt: true },
    _max: { amount: true, createdAt: true }
  }
});

// All typed correctly based on field types
// number fields return number | null
// date fields return Date | null

Full TypeScript Support

const result = await db.users.findMany({
  partitionKey: "john@example.com",
  aggregate: {
    _count: true,
    _avg: { age: true },
    _sum: { score: true }
  }
});

// Type: { 
//   data: User[], 
//   _count: number,
//   _avg: { age: number | null },
//   _sum: { score: number | null }
// }

Group By Operations

Group By is the most powerful aggregation feature - it splits data into groups and performs aggregations on each group.

Group by Single Field

const salesSchema = container('sales', {
  id: field.string(),
  storeId: field.string(),
  category: field.string(),
  amount: field.number(),
  quantity: field.number(),
}).partitionKey('storeId');

// Sales by category
const categoryStats = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _count: true,
  _sum: { amount: true, quantity: true },
  _avg: { amount: true },
});

// Result type (fully inferred):
// Array<{
//   category: string;
//   _count: number;
//   _sum: { amount: number | null; quantity: number | null };
//   _avg: { amount: number | null };
// }>

// Use the results
for (const group of categoryStats) {
  console.log(`${group.category}:
    Sales: $${group._sum.amount}
    Units: ${group._sum.quantity}
    Avg: $${group._avg.amount}
  `);
}

Group by Multiple Fields

Use an array to group by multiple fields:

// Sales by store AND category
const detailedStats = await db.sales.groupBy({
  by: ['storeId', 'category'] as const,  // 'as const' for better type inference
  enableCrossPartitionQuery: true,
  _count: true,
  _sum: { amount: true },
});

// Result type:
// Array<{
//   storeId: string;
//   category: string;
//   _count: number;
//   _sum: { amount: number | null };
// }>

// Find best performing store-category combination
const topPerformer = detailedStats.reduce((max, group) => 
  (group._sum.amount || 0) > (max._sum.amount || 0) ? group : max
);

console.log(`Top: ${topPerformer.storeId} - ${topPerformer.category}`);

Group By with Ordering

Sort results by aggregation values:

// Top categories by revenue
const topCategories = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _count: true,
  _sum: { amount: true },
  orderBy: {
    _sum_amount: 'desc',  // Order by aggregated field
  },
  take: 10,  // Top 10
});

// Categories with most transactions
const popularCategories = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _count: true,
  orderBy: {
    _count: 'desc',
  },
  take: 5,
});

Group By with Pagination

// Paginated category report
const page1 = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _sum: { amount: true },
  orderBy: { category: 'asc' },
  take: 20,
  skip: 0,
});

const page2 = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _sum: { amount: true },
  orderBy: { category: 'asc' },
  take: 20,
  skip: 20,
});

Group By with Filters

Combine WHERE clauses with GROUP BY:

const userSchema = container('analytics', {
  id: field.string(),
  userId: field.string(),
  eventType: field.string(),
  country: field.string(),
  timestamp: field.date(),
  duration: field.number(),
}).partitionKey('userId');

// Events by type for a specific user in the last 30 days
const recentEvents = await db.analytics.groupBy({
  by: 'eventType',
  partitionKey: 'user-123',
  where: {
    timestamp: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
  },
  _count: true,
  _avg: { duration: true },
});

// Cross-partition: Events by country for premium users
const countryStats = await db.analytics.groupBy({
  by: 'country',
  enableCrossPartitionQuery: true,
  where: {
    eventType: 'purchase',
  },
  _count: true,
  _sum: { duration: true },
});

Convenience Methods

For simple single-field aggregations, use convenience methods:

sum()

// Quick sum - cleaner than aggregate()
const totalRevenue = await db.orders.sum('amount', {
  partitionKey: 'customer-123',
});
// Returns: number | null

// With filter
const completedRevenue = await db.orders.sum('amount', {
  partitionKey: 'customer-123',
  where: { status: 'completed' },
});

avg()

// Average order value
const avgOrderValue = await db.orders.avg('amount', {
  partitionKey: 'customer-123',
});
// Returns: number | null

// Average rating for a product
const avgRating = await db.reviews.avg('rating', {
  partitionKey: 'product-456',
  where: { verified: true },
});

min()

// Find cheapest product
const cheapestPrice = await db.products.min('price', {
  partitionKey: 'electronics',
});
// Returns: number | null

// Earliest order date
const firstOrder = await db.orders.min('createdAt', {
  partitionKey: 'customer-123',
});
// Returns: Date | null (type inferred from field!)

max()

// Most expensive product
const highestPrice = await db.products.max('price', {
  partitionKey: 'electronics',
});

// Latest activity
const lastSeen = await db.users.max('lastLoginAt', {
  partitionKey: 'tenant-1',
});
// Returns: Date | null

Type Safety

CosmosQL provides complete type inference for all aggregation operations.

Automatic Type Inference

const orderSchema = container('orders', {
  id: field.string(),
  customerId: field.string(),
  amount: field.number(),
  tax: field.number().optional(),
  status: field.string(),
  createdAt: field.date(),
}).partitionKey('customerId');

// TypeScript knows exact types
const stats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _count: true,
  _sum: { amount: true, tax: true },
  _min: { createdAt: true },
  _max: { createdAt: true },
});

// ✅ All these are correctly typed:
const count: number = stats._count;
const total: number | null = stats._sum.amount;
const taxTotal: number | null = stats._sum.tax;
const firstOrder: Date | null = stats._min.createdAt;
const lastOrder: Date | null = stats._max.createdAt;

// ❌ TypeScript errors:
// stats._sum.status;  // Error: 'status' is not a number field
// stats._avg.createdAt;  // Error: 'createdAt' is not a number field

Group By Type Inference

// Single field grouping
const result1 = await db.orders.groupBy({
  by: 'status',
  enableCrossPartitionQuery: true,
  _count: true,
  _sum: { amount: true },
});
// Type: Array<{ status: string; _count: number; _sum: { amount: number | null } }>

// Multiple field grouping
const result2 = await db.orders.groupBy({
  by: ['customerId', 'status'] as const,
  enableCrossPartitionQuery: true,
  _count: true,
});
// Type: Array<{ customerId: string; status: string; _count: number }>

// Access fields with full type safety
for (const group of result1) {
  console.log(group.status);      // ✅ string
  console.log(group._count);      // ✅ number
  console.log(group._sum.amount); // ✅ number | null
}

Optional Fields

const productSchema = container('products', {
  id: field.string(),
  category: field.string(),
  price: field.number(),
  salePrice: field.number().optional(),  // Optional field
}).partitionKey('category');

// TypeScript knows salePrice might be undefined
const result = await db.products.aggregate({
  partitionKey: 'electronics',
  _sum: { price: true, salePrice: true },  // ✅ Works with optional fields
  _avg: { salePrice: true },
});

// All aggregation results are nullable
const total: number | null = result._sum.price;
const saleTotal: number | null = result._sum.salePrice;

Type-Safe Field Selection

// Only number fields can be summed/averaged
await db.orders.aggregate({
  partitionKey: 'customer-123',
  _sum: {
    amount: true,    // ✅ number field
    // status: true,  // ❌ Error: 'status' is not a number
  },
  _avg: {
    amount: true,    // ✅ number field
    // createdAt: true,  // ❌ Error: dates can't be averaged
  },
});

// Any field can be used with min/max/count
await db.orders.aggregate({
  partitionKey: 'customer-123',
  _min: {
    amount: true,     // ✅ number
    status: true,     // ✅ string
    createdAt: true,  // ✅ date
  },
});

Real-World Examples

E-Commerce Analytics Dashboard

// Schema
const orderSchema = container('orders', {
  id: field.string(),
  customerId: field.string(),
  status: field.string(),
  totalAmount: field.number(),
  itemCount: field.number(),
  discount: field.number().optional(),
  createdAt: field.date(),
}).partitionKey('customerId');

// Customer lifetime value
async function getCustomerLTV(customerId: string) {
  const stats = await db.orders.aggregate({
    partitionKey: customerId,
    where: { status: 'completed' },
    _count: true,
    _sum: { totalAmount: true, itemCount: true, discount: true },
    _avg: { totalAmount: true },
    _min: { createdAt: true },
    _max: { createdAt: true },
  });

  return {
    totalOrders: stats._count,
    lifetimeValue: stats._sum.totalAmount || 0,
    totalItems: stats._sum.itemCount || 0,
    totalSavings: stats._sum.discount || 0,
    avgOrderValue: stats._avg.totalAmount || 0,
    firstOrderDate: stats._min.createdAt,
    lastOrderDate: stats._max.createdAt,
    customerSince: stats._min.createdAt 
      ? Math.floor((Date.now() - stats._min.createdAt.getTime()) / (1000 * 60 * 60 * 24))
      : 0,
  };
}

// Sales by status report
async function getSalesStatusReport() {
  const statusReport = await db.orders.groupBy({
    by: 'status',
    enableCrossPartitionQuery: true,
    _count: true,
    _sum: { totalAmount: true, itemCount: true },
    _avg: { totalAmount: true },
  });

  return statusReport.map(group => ({
    status: group.status,
    count: group._count,
    revenue: group._sum.totalAmount || 0,
    items: group._sum.itemCount || 0,
    avgValue: group._avg.totalAmount || 0,
  }));
}

// Top customers
async function getTopCustomers(limit: number = 10) {
  const customers = await db.orders.groupBy({
    by: 'customerId',
    enableCrossPartitionQuery: true,
    _count: true,
    _sum: { totalAmount: true },
    orderBy: { _sum_totalAmount: 'desc' },
    take: limit,
  });

  return customers.map(c => ({
    customerId: c.customerId,
    orders: c._count,
    spent: c._sum.totalAmount || 0,
  }));
}

Analytics Platform

const eventSchema = container('events', {
  id: field.string(),
  userId: field.string(),
  eventType: field.string(),
  page: field.string(),
  duration: field.number(),
  timestamp: field.date(),
}).partitionKey('userId');

// User engagement metrics
async function getUserEngagement(userId: string, days: number = 30) {
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
  
  const metrics = await db.events.aggregate({
    partitionKey: userId,
    where: { timestamp: { gte: since } },
    _count: true,
    _sum: { duration: true },
    _avg: { duration: true },
  });

  const eventBreakdown = await db.events.groupBy({
    by: 'eventType',
    partitionKey: userId,
    where: { timestamp: { gte: since } },
    _count: true,
    _avg: { duration: true },
  });

  return {
    totalEvents: metrics._count,
    totalTime: metrics._sum.duration || 0,
    avgSessionTime: metrics._avg.duration || 0,
    eventTypes: eventBreakdown,
  };
}

// Platform-wide statistics
async function getPlatformStats() {
  // Events by type
  const eventTypes = await db.events.groupBy({
    by: 'eventType',
    enableCrossPartitionQuery: true,
    where: {
      timestamp: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
    },
    _count: true,
    _avg: { duration: true },
    orderBy: { _count: 'desc' },
  });

  // Page engagement
  const pages = await db.events.groupBy({
    by: 'page',
    enableCrossPartitionQuery: true,
    where: {
      timestamp: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
    },
    _count: true,
    _sum: { duration: true },
    orderBy: { _count: 'desc' },
    take: 10,
  });

  return { eventTypes, topPages: pages };
}

Inventory Management

const productSchema = container('products', {
  id: field.string(),
  warehouseId: field.string(),
  category: field.string(),
  stock: field.number(),
  reorderPoint: field.number(),
  cost: field.number(),
  price: field.number(),
}).partitionKey('warehouseId');

// Warehouse inventory summary
async function getWarehouseSummary(warehouseId: string) {
  const summary = await db.products.aggregate({
    partitionKey: warehouseId,
    _count: true,
    _sum: { stock: true, cost: true, price: true },
  });

  const categoryBreakdown = await db.products.groupBy({
    by: 'category',
    partitionKey: warehouseId,
    _count: true,
    _sum: { stock: true },
    _avg: { price: true },
  });

  const inventoryValue = (summary._sum.cost || 0) * (summary._sum.stock || 0);
  const retailValue = (summary._sum.price || 0) * (summary._sum.stock || 0);

  return {
    totalProducts: summary._count,
    totalStock: summary._sum.stock || 0,
    inventoryValue,
    retailValue,
    potentialProfit: retailValue - inventoryValue,
    byCategory: categoryBreakdown,
  };
}

// Low stock alerts
async function getLowStockItems(warehouseId: string) {
  const items = await db.products.findMany({
    partitionKey: warehouseId,
    where: {
      stock: { lte: field.number() }, // Stock <= reorderPoint
    },
  });

  // Get summary stats
  const stats = await db.products.aggregate({
    partitionKey: warehouseId,
    where: {
      stock: { lte: field.number() },
    },
    _count: true,
    _sum: { stock: true },
  });

  return {
    items,
    totalLowStockItems: stats._count,
    totalLowStockUnits: stats._sum.stock || 0,
  };
}

Performance & Best Practices

✅ DO: Use Partition Keys

Always provide a partition key when possible:

// ✅ GOOD - Single partition query (fast & cheap)
const count = await db.orders.count({
  partitionKey: 'customer-123',
});

// ⚠️ AVOID - Cross-partition query (slow & expensive)
const count = await db.orders.count({
  enableCrossPartitionQuery: true,
});

✅ DO: Combine Multiple Aggregations

More efficient to get multiple aggregations in one query:

// ✅ GOOD - Single query
const stats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _count: true,
  _sum: { amount: true },
  _avg: { amount: true },
});

// ❌ BAD - Three separate queries
const count = await db.orders.count({ partitionKey: 'customer-123' });
const sum = await db.orders.sum('amount', { partitionKey: 'customer-123' });
const avg = await db.orders.avg('amount', { partitionKey: 'customer-123' });

✅ DO: Use WHERE Clauses

Filter data to reduce computation:

// ✅ GOOD - Only aggregate what you need
const completedStats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  where: { status: 'completed' },
  _sum: { amount: true },
});

// ⚠️ AVOID - Aggregating everything then filtering
const allStats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _sum: { amount: true },
});
// Then filtering in application code

✅ DO: Use Pagination for Large Results

// ✅ GOOD - Paginate large group by results
async function* getAllGroups() {
  let skip = 0;
  const pageSize = 100;
  
  while (true) {
    const page = await db.sales.groupBy({
      by: 'category',
      enableCrossPartitionQuery: true,
      _sum: { amount: true },
      take: pageSize,
      skip,
    });
    
    if (page.length === 0) break;
    yield* page;
    skip += pageSize;
  }
}

⚠️ AVOID: Unnecessary Cross-Partition Queries

// ❌ BAD - Expensive cross-partition query
const total = await db.orders.sum('amount', {
  enableCrossPartitionQuery: true,
});

// ✅ BETTER - Store aggregates separately if needed frequently
// Use a separate container or materialized views

⚠️ AVOID: Aggregating Large Date Ranges Without Filters

// ❌ BAD - Scanning entire history
const allTimeStats = await db.orders.aggregate({
  enableCrossPartitionQuery: true,
  _sum: { amount: true },
});

// ✅ BETTER - Limit to relevant time period
const recentStats = await db.orders.aggregate({
  enableCrossPartitionQuery: true,
  where: {
    createdAt: { gte: new Date('2024-01-01') },
  },
  _sum: { amount: true },
});

Understanding NULL Results

Aggregations return null when there are no rows:

const stats = await db.orders.aggregate({
  partitionKey: 'new-customer',
  _sum: { amount: true },
  _avg: { amount: true },
});

// stats._sum.amount might be null (no orders)
// Always handle null case:
const total = stats._sum.amount ?? 0;
const average = stats._avg.amount ?? 0;

// Or use nullish coalescing in display:
console.log(`Total: $${stats._sum.amount || 0}`);

Cosmos DB RU Costs

Aggregation operations consume Request Units (RUs):

  • Single Partition: ~2-5 RUs (very efficient)
  • Cross-Partition: 5-100+ RUs depending on data size
  • Group By: Higher RU cost, especially cross-partition

Monitor your RU consumption and optimize queries accordingly.


Advanced Patterns

Combining Aggregations with Find

// Get both detail and summary
async function getOrderReport(customerId: string) {
  const [orders, stats] = await Promise.all([
    db.orders.findMany({
      partitionKey: customerId,
      orderBy: { createdAt: 'desc' },
      take: 10,
    }),
    db.orders.aggregate({
      partitionKey: customerId,
      _count: true,
      _sum: { amount: true },
      _avg: { amount: true },
    }),
  ]);

  return { recentOrders: orders, summary: stats };
}

Calculating Percentages

const statusBreakdown = await db.orders.groupBy({
  by: 'status',
  enableCrossPartitionQuery: true,
  _count: true,
});

const total = statusBreakdown.reduce((sum, g) => sum + g._count, 0);

const withPercentages = statusBreakdown.map(group => ({
  status: group.status,
  count: group._count,
  percentage: ((group._count / total) * 100).toFixed(2) + '%',
}));

Time-Series Aggregations

// Daily sales for last 30 days
async function getDailySales(days: number = 30) {
  const results = [];
  
  for (let i = 0; i < days; i++) {
    const date = new Date();
    date.setDate(date.getDate() - i);
    const startOfDay = new Date(date.setHours(0, 0, 0, 0));
    const endOfDay = new Date(date.setHours(23, 59, 59, 999));

    const stats = await db.orders.aggregate({
      enableCrossPartitionQuery: true,
      where: {
        createdAt: { gte: startOfDay, lte: endOfDay },
        status: 'completed',
      },
      _count: true,
      _sum: { amount: true },
    });

    results.push({
      date: startOfDay,
      orders: stats._count,
      revenue: stats._sum.amount || 0,
    });
  }

  return results;
}

Error Handling

import { CosmosError } from 'cosmosql';

try {
  const stats = await db.orders.aggregate({
    // partitionKey missing!
    _sum: { amount: true },
  });
} catch (error) {
  if (error instanceof CosmosError) {
    if (error.statusCode === 429) {
      // Rate limited - retry with backoff
      console.log('Rate limited, retry after:', error.retryAfter);
    } else if (error.code === 'PARTITION_KEY_REQUIRED') {
      // Missing partition key
      console.error('Must provide partition key or enable cross-partition query');
    }
  }
  throw error;
}

Migration from Raw Cosmos DB

If you're migrating from raw Cosmos DB SDK:

Before (Raw SDK)

const querySpec = {
  query: 'SELECT COUNT(1) as count, SUM(c.amount) as total FROM c WHERE c.customerId = @customerId',
  parameters: [{ name: '@customerId', value: 'customer-123' }],
};

const { resources } = await container.items.query(querySpec).fetchAll();
const result = resources[0];

After (CosmosQL)

const result = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _count: true,
  _sum: { amount: true },
});
// Result is fully typed!

Summary

CosmosQL's aggregation functions provide:

  1. Type Safety - Full TypeScript inference for all operations
  2. Clean API - Intuitive, composable operations
  3. Performance - Optimized queries with partition awareness
  4. Flexibility - From simple counts to complex group by operations
  5. Developer Experience - IntelliSense, autocompletion, and compile-time checks

Start with simple count() operations, then explore aggregate() for multiple operations, and finally groupBy() for advanced analytics. Always consider partition keys and cross-partition costs when designing your queries.

For more examples, see the API reference or join our community for support.

Common Patterns

Real-world usage patterns for authentication, multi-tenant apps, time-series data, and more

View on separate page →

Real-world solutions to common problems with CosmosQL.

Organized by use case:


Authentication

The pattern: Use email as both ID and partition key for user documents.

Why this works:

  • Login queries are partition-scoped (fast + cheap)
  • Natural 1:1 mapping between user and partition
  • Scales to millions of users
  • No "hot partitions" (even distribution)

When to avoid:

  • Users can change email addresses → Use immutable userId instead
  • Multiple authentication providers → Requires composite keys

Schema Design

const users = container('users', {
  id: field.string(),        // Using email as ID
  email: field.string(),     // Also the partition key
  passwordHash: field.string(),
  profile: field.object({
    name: field.string(),
    avatar: field.string().optional()
  }),
  createdAt: field.date(),
  lastLoginAt: field.date().optional()
}).partitionKey('email'); // Key decision: email is partition key

Why email as both ID and partition key?

  • Point reads during login (fastest possible: 1 RU)
  • No duplicate emails (ID uniqueness)
  • Even distribution across partitions

Registration

async function register(email: string, password: string) {
  const passwordHash = await bcrypt.hash(password, 10);
  
  try {
    const user = await db.users.create({
      data: {
        id: email,           // Using email as ID
        email: email,        // And partition key
        passwordHash,
        profile: { name: email.split('@')[0] },
        createdAt: new Date()
      }
    });
    
    return { success: true, user };
  } catch (error) {
    if (isCosmosError(error) && error.statusCode === 409) {
      // Conflict = duplicate email
      return { success: false, error: 'Email already exists' };
    }
    throw error;
  }
}

What makes this efficient:

  • One partition write (5 RU)
  • Automatic duplicate detection (409 conflict)
  • Type-safe return values

Login

async function login(email: string, password: string) {
  // Point read: fastest possible query (1 RU)
  const user = await db.users.findUnique({
    where: {
      id: email,
      email: email // Partition key
    },
    select: {
      id: true,
      email: true,
      passwordHash: true,
      profile: true
    }
  });
  
  if (!user) {
    return { success: false, error: 'Invalid credentials' };
  }
  
  // Verify password
  const valid = await bcrypt.compare(password, user.passwordHash);
  if (!valid) {
    return { success: false, error: 'Invalid credentials' };
  }
  
  // Update last login (separate operation to keep read fast)
  await db.users.update({
    where: { id: email, email },
    data: { lastLoginAt: new Date() }
  });
  
  return { success: true, user };
}

Performance breakdown:

  • Read: 1 RU (point read)
  • Update: 5 RU (partition-scoped write)
  • Total: 6 RU per login

Alternative: Skip the update to save 5 RU if you don't need lastLoginAt.

Email Change (Advanced)

Problem: User wants to change email, but email is the partition key.

Solution: Create new document, migrate data, delete old.

async function changeEmail(oldEmail: string, newEmail: string) {
  // 1. Check if new email exists
  const existing = await db.users.findUnique({
    where: { id: newEmail, email: newEmail }
  });
  
  if (existing) {
    return { success: false, error: 'Email already in use' };
  }
  
  // 2. Get current user
  const user = await db.users.findUnique({
    where: { id: oldEmail, email: oldEmail }
  });
  
  if (!user) {
    return { success: false, error: 'User not found' };
  }
  
  // 3. Create new document with new email
  await db.users.create({
    data: {
      ...user,
      id: newEmail,
      email: newEmail
    }
  });
  
  // 4. Delete old document
  await db.users.delete({
    where: { id: oldEmail, email: oldEmail }
  });
  
  return { success: true };
}

Cost: ~15 RU (read + create + delete)
Caveat: Not atomic—handle failures appropriately


Multi-Tenant SaaS

The pattern: Use tenantId as partition key to ensure data isolation.

Why this works:

  • Complete isolation between tenants
  • All queries automatically scoped to a single tenant
  • Prevents cross-tenant data leakage
  • Scales as tenants grow

Schema Design

const organizations = container('organizations', {
  id: field.string(),
  name: field.string(),
  plan: field.string(),
  settings: field.object({
    theme: field.string().default('light'),
    features: field.array(field.string())
  })
}).partitionKey('id'); // Organization ID as partition key

const users = container('users', {
  id: field.string(),
  organizationId: field.string(),
  email: field.string(),
  role: field.string(),
  profile: field.object({
    name: field.string(),
    department: field.string().optional()
  })
}).partitionKey('organizationId'); // All users for an org in same partition

const documents = container('documents', {
  id: field.string(),
  tenantId: field.string(),
  name: field.string(),
  content: field.string(),
  createdBy: field.string(),
  createdAt: field.date()
}).partitionKey('tenantId');

Querying Tenant Data

// All queries automatically scoped to tenant
async function getDocuments(tenantId: string) {
  return await db.documents.findMany({
    partitionKey: tenantId,
    orderBy: { createdAt: 'desc' }
  });
}

async function getOrgUsers(organizationId: string) {
  return await db.users.findMany({
    partitionKey: organizationId,
    where: {
      role: 'admin'
    },
    select: {
      id: true,
      email: true,
      profile: { name: true }
    }
  });
}

E-Commerce

The pattern: Partition by category for efficient product queries.

Why this works:

  • Fast category browsing
  • Good distribution across partitions
  • Cross-partition queries only for global search

Schema Design

const products = container('products', {
  id: field.string(),
  category: field.string(), // Partition key
  name: field.string(),
  price: field.number(),
  inventory: field.number(),
  tags: field.array(field.string()),
  metadata: field.object({
    brand: field.string(),
    weight: field.number().optional(),
    dimensions: field.object({
      length: field.number(),
      width: field.number(),
      height: field.number()
    }).optional()
  })
}).partitionKey('category');

Product Queries

// Query products by category with filtering
const electronics = await db.products.findMany({
  partitionKey: 'electronics',
  where: {
    price: { lte: 999 },
    inventory: { gt: 0 },
    tags: { contains: 'wireless' }
  },
  select: {
    id: true,
    name: true,
    price: true,
    metadata: {
      brand: true
    }
  },
  orderBy: { price: 'asc' },
  take: 20
});

Social Platform

The pattern: Partition by userId to keep all user content together.

Why this works:

  • Fast queries for a user's posts, comments, activity
  • All related data in one partition
  • Efficient for user profiles and feeds

Schema Design

const posts = container('posts', {
  id: field.string(),
  userId: field.string(), // Partition key
  content: field.string(),
  likes: field.number().default(0),
  createdAt: field.date(),
  tags: field.array(field.string()),
  author: field.object({
    name: field.string(),
    avatar: field.string().optional()
  })
}).partitionKey('userId');

User Content Queries

// Find user's recent posts with engagement
const userPosts = await db.posts.findMany({
  partitionKey: 'user_123',
  where: {
    createdAt: { gte: new Date('2024-01-01') },
    likes: { gt: 10 }
  },
  select: {
    id: true,
    content: true,
    likes: true,
    author: {
      name: true
    }
  },
  orderBy: { createdAt: 'desc' },
  take: 50
});

Time-Series Data

The pattern: Use deviceId as partition key with TTL for automatic cleanup.

Why this works:

  • Fast queries per device
  • Automatic expiration with TTL
  • Good distribution across devices

Schema Design

const metrics = container('metrics', {
  id: field.string(),
  deviceId: field.string(), // Partition key
  timestamp: field.date(),
  temperature: field.number(),
  humidity: field.number(),
  ttl: field.number() // Auto-delete old data
}).partitionKey('deviceId');

Time-Series Queries

// Ingest metric (auto-expires after 30 days)
await db.metrics.create({
  data: {
    id: generateId(),
    deviceId: 'device_123',
    timestamp: new Date(),
    temperature: 22.5,
    humidity: 45,
    ttl: 30 * 24 * 60 * 60 // 30 days in seconds
  }
});

// Query recent metrics for device
const recent = await db.metrics.findMany({
  partitionKey: 'device_123',
  where: {
    timestamp: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
  },
  orderBy: { timestamp: 'desc' }
});

Shopping Cart

The pattern: Use userId as partition key with TTL for abandoned carts.

const carts = container('carts', {
  id: field.string(),
  userId: field.string(),
  items: field.array(field.object({
    productId: field.string(),
    quantity: field.number(),
    price: field.number()
  })),
  total: field.number(),
  updatedAt: field.date(),
  ttl: field.number() // Expire abandoned carts
}).partitionKey('userId');

Cart Operations

async function addToCart(userId: string, item: CartItem) {
  const cart = await db.carts.findUnique({
    where: { id: userId, userId }
  });
  
  if (!cart) {
    return await db.carts.create({
      data: {
        id: userId,
        userId,
        items: [item],
        total: item.price * item.quantity,
        updatedAt: new Date(),
        ttl: 7 * 24 * 60 * 60 // Expire after 7 days
      }
    });
  }
  
  // Update existing cart
  const newItems = [...cart.items, item];
  const newTotal = newItems.reduce((sum, i) => sum + i.price * i.quantity, 0);
  
  return await db.carts.update({
    where: { id: userId, userId },
    data: {
      items: newItems,
      total: newTotal,
      updatedAt: new Date()
    }
  });
}

Next Steps

Core Concepts

Understand CosmosDB fundamentals, partition keys, and how CosmosQL prevents costly mistakes

View on separate page →

Why this matters: CosmosDB has unique characteristics that shape how you query data. Understanding these fundamentals helps you write efficient, cost-effective code.

Skip this if: You're already a CosmosDB expert. Jump to Creating Documents.

Read this if: CosmosDB is new, or you want to understand the "why" behind CosmosQL's design.


The Distributed Database Problem

CosmosDB isn't one server—it's thousands of servers working together. Your data is split across them.

This creates a challenge: How do you query data when it's spread across hundreds of machines?

The solution: Partition keys.

Partition Keys: The $2,400 Question

Every document in CosmosDB has a partition key. This value determines which server stores it.

Why This Matters (Real Scenario)

A developer wrote this query:

// Find all active users
const users = await findWhere({ isActive: true });

With 1,000 users: Cost $5/month, worked fine.
With 100,000 users: Cost $2,400/month, still worked fine.

What went wrong? No partition key = CosmosDB scans every server.

The Cost Breakdown

Query TypeServers ScannedRequest UnitsMonthly Cost (1M queries)
With partition key11 RU$24
Without partition keyAll (100+)100 RU$2,400

100x difference.

How CosmosQL Prevents This

// ❌ TypeScript won't compile this
const users = await db.users.findMany({
  where: { isActive: true }
  // Error: Missing required property 'partitionKey'
});

// ✅ Forces you to be explicit
const users = await db.users.findMany({
  partitionKey: 'user@example.com', // Scans one partition
  where: { isActive: true }
});

// ✅ Or opt-in to cross-partition (expensive) queries
const users = await db.users.findMany({
  enableCrossPartitionQuery: true, // "I know this is expensive"
  where: { isActive: true }
});

You cannot accidentally write expensive queries. TypeScript won't let your code compile.

Choosing a Good Partition Key

Good partition keys have three properties:

1. High Cardinality (Many Unique Values)

// ✅ Good: email addresses
const users = container('users', schema).partitionKey('email');

// ❌ Bad: boolean status
const users = container('users', schema).partitionKey('isActive');
// Only 2 values → only 2 partitions → poor distribution

2. Even Distribution

// ✅ Good: user IDs spread evenly
const posts = container('posts', schema).partitionKey('userId');

// ⚠️ Risky: category (one category could be huge)
const products = container('products', schema).partitionKey('category');
// "Electronics" might be 80% of your data → hot partition

3. Query Alignment

Choose keys that match your most common query patterns:

// If you always query by user
const posts = container('posts', schema).partitionKey('userId'); // ✅

// If you always query by date
const metrics = container('metrics', schema).partitionKey('timestamp'); // ✅

// If you query by multiple fields
const orders = container('orders', schema).partitionKey('customerId'); // ✅

Request Units (RU): How CosmosDB Charges

CosmosDB uses Request Units (RUs) to measure and charge for operations.

Typical Costs

OperationCostNotes
Point read1 RUReading by ID + partition key
Partition query3-5 RUFiltering within one partition
Cross-partition query50-100+ RUScanning all partitions
Write5-10 RUCreating or updating

Real Cost Example

If you process 1 million queries per month:

// Partition-scoped queries
1M queries × 3 RU = 3M RU/month ≈ $72/month

// Cross-partition queries  
1M queries × 100 RU = 100M RU/month ≈ $2,400/month

That's 33x more expensive.

Schema Definition

Schemas in CosmosQL are TypeScript-only - they don't exist at runtime.

import { container, field } from 'cosmosql';

const posts = container('posts', {
  // Required fields
  id: field.string(),
  userId: field.string(),
  title: field.string(),
  
  // Optional fields
  subtitle: field.string().optional(),
  
  // Defaults
  viewCount: field.number().default(0),
  isPublished: field.boolean().default(false),
  
  // Arrays
  tags: field.array(field.string()),
  
  // Nested objects
  metadata: field.object({
    source: field.string(),
    version: field.number()
  }).optional(),
  
  // TTL (auto-delete after N seconds)
  ttl: field.number().optional()
}).partitionKey('userId');

// Get TypeScript type
type Post = typeof posts.infer;

Key point: This is purely for TypeScript. CosmosDB is schemaless - CosmosQL just adds type safety.

Field Types

  • field.string()string
  • field.number()number
  • field.boolean()boolean
  • field.date()Date
  • field.array(type)Array<type>
  • field.object(schema) → nested object

Modifiers

  • .optional() → field can be undefined
  • .default(value) → value used if not provided on create

Client Setup

import { createClient } from 'cosmosql';

const db = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'myapp',
  mode: 'auto-create' // Validates and creates containers
}).withContainers({
  users,
  posts,
  comments
});

// Now you have typed access
db.users     // ContainerClient<User>
db.posts     // ContainerClient<Post>
db.comments  // ContainerClient<Comment>

Client creation is async because it validates and optionally creates your database and containers. Choose a mode based on your environment:

  • auto-create - Development: creates missing containers automatically
  • verify - Production: fails fast if containers don't exist
  • skip - Maximum performance: no validation

See Getting Started Guide for detailed mode documentation.

Connection management: HTTP/2 pooling with automatic retries. You don't manage connections.

Multiple databases: Create separate clients:

const prodDb = await createClient({...}).withContainers({users});
const analyticsDb = await createClient({...}).withContainers({events});

Summary

Three key takeaways:

  1. Partition keys determine cost - Queries without them cost 10-100x more
  2. CosmosQL enforces them at compile time - TypeScript prevents expensive mistakes
  3. Schemas are TypeScript-only - Zero runtime overhead, all compile-time checks

Next: Apply these concepts in Creating Documents.

Creating Documents

Learn how to create and insert documents into CosmosDB with CosmosQL

View on separate page →

Complete guide to inserting documents into CosmosDB.

Navigation:


Single Document

Use when: Creating one document at a time.

Example scenario: User registration.

const newUser = await db.users.create({
  data: {
    id: 'user_123',
    email: 'john@example.com',
    name: 'John Doe',
    age: 30,
    createdAt: new Date()
    // Optional fields with defaults are automatically applied
  }
});
// Returns: Fully typed User object

What happens:

  1. CosmosQL generates a POST request to CosmosDB REST API
  2. Document is stored in the partition determined by email
  3. Return value is fully typed based on your schema

Cost: ~5 RU per document

Default Values

Use when: Your schema defines defaults for optional fields.

const users = container('users', {
  id: field.string(),
  email: field.string(),
  isActive: field.boolean().default(true), // Default value
  createdAt: field.date()
}).partitionKey('email');

// No need to provide isActive
const user = await db.users.create({
  data: {
    id: 'user_123',
    email: 'john@example.com',
    createdAt: new Date()
    // isActive automatically true
  }
});

Bulk Operations

Use when: Creating multiple documents in one request.

Requirement: All documents MUST share the same partition key.

Why: CosmosDB batch operations only work within a single partition.

Example scenario: Importing user posts.

await db.posts.createMany({
  data: [
    { id: 'post_1', userId: 'user_123', title: 'First', content: '...' },
    { id: 'post_2', userId: 'user_123', title: 'Second', content: '...' },
    { id: 'post_3', userId: 'user_123', title: 'Third', content: '...' }
  ],
  partitionKey: 'user_123' // Must be same for all documents
});

Cost: ~5 RU per document (same as single creates)
Performance: Single HTTP request vs multiple

Error handling: If any document fails, none are created (atomic within partition).

Best Practices

1. Always Provide IDs Explicitly

// ✅ Good: Explicit ID
const user = await db.users.create({
  data: {
    id: `user_${Date.now()}`, // Predictable
    email: 'user@example.com',
    // ...
  }
});

2. Use Meaningful IDs

// ✅ Good: Human-readable
'user_john@example.com'
'order_2024-01-15-abc123'

// ❌ Bad: Random UUIDs (unless you need them)
'a3f5b8c2-d4e6-7890-abcd-ef1234567890'

3. Set Timestamps Explicitly

const user = await db.users.create({
  data: {
    id: 'user_123',
    email: 'john@example.com',
    createdAt: new Date(), // Server time
    updatedAt: new Date()
  }
});

Error Handling

try {
  const user = await db.users.create({
    data: {
      id: 'user_123',
      email: 'john@example.com',
      // ...
    }
  });
} catch (error) {
  if (error.code === 409) {
    // Document with this ID already exists
    console.error('User already exists');
  } else if (error.code === 429) {
    // Rate limit exceeded
    console.error('Rate limit exceeded, please retry');
  } else {
    console.error('Failed to create user:', error);
  }
}

Common Patterns

Pattern: Multi-Tenant Applications

// Partition by tenant ID
const posts = container('posts', {
  id: field.string(),
  tenantId: field.string(),
  title: field.string(),
  // ...
}).partitionKey('tenantId');

// Create posts scoped to a tenant
await db.posts.createMany({
  data: [
    { id: 'post_1', tenantId: 'tenant_abc', title: '...' },
    { id: 'post_2', tenantId: 'tenant_abc', title: '...' }
  ],
  partitionKey: 'tenant_abc'
});

Pattern: Hierarchical Data

// Create parent-child relationships
const category = await db.categories.create({
  data: {
    id: 'cat_tech',
    name: 'Technology',
    parentId: null,
    createdAt: new Date()
  }
});

const subcategory = await db.categories.create({
  data: {
    id: 'cat_tech_ai',
    name: 'Artificial Intelligence',
    parentId: 'cat_tech',
    createdAt: new Date()
  }
});

Next Steps

Deleting Documents

Learn how to delete documents and implement soft delete patterns

View on separate page →

Deleting documents requires careful consideration. Use hard deletes for performance, soft deletes for recovery.

Navigation:


Hard Delete

The basic delete operation removes a document permanently:

await db.users.delete({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  }
});

Important: Deletion is permanent and cannot be undone. Always ensure you have the partition key in the where clause—this is enforced at compile time.

Cost: ~5 RU

Soft Delete

Instead of permanently deleting documents, mark them as deleted:

// Instead of deleting, mark as inactive
await db.users.update({
  where: { id: 'user_123', email: 'john@example.com' },
  data: {
    isActive: false,
    deletedAt: new Date()
  }
});

// Query only active users
const activeUsers = await db.users.findMany({
  partitionKey: 'tenant_123',
  where: {
    isActive: true
  }
});

Why Soft Delete?

  • Data Recovery: Can restore deleted data
  • Audit Trail: Keep history of what was deleted and when
  • Referential Integrity: Other documents can still reference deleted items
  • Analytics: Analyze deleted data patterns

Implementing Soft Delete

Complete implementation:

// Define schema with soft delete fields
const users = container('users', {
  id: field.string(),
  email: field.string(),
  name: field.string(),
  isDeleted: field.boolean().default(false),
  deletedAt: field.date().optional(),
  deletedBy: field.string().optional()
}).partitionKey('email');

// Helper functions
async function softDeleteUser(id: string, email: string, deletedBy: string) {
  return await db.users.update({
    where: { id, email },
    data: {
      isDeleted: true,
      deletedAt: new Date(),
      deletedBy
    }
  });
}

async function restoreUser(id: string, email: string) {
  return await db.users.update({
    where: { id, email },
    data: {
      isDeleted: false,
      deletedAt: null,
      deletedBy: null
    }
  });
}

// Query excluding soft-deleted items
async function findAllUsers(partitionKey: string) {
  return await db.users.findMany({
    partitionKey,
    where: {
      isDeleted: false
    }
  });
}

Cleanup Old Soft Deletes

Automatically clean up old soft-deleted items:

async function cleanupOldSoftDeletes() {
  const thirtyDaysAgo = new Date();
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

  const deletedUsers = await db.users.findMany({
    enableCrossPartitionQuery: true,
    where: {
      isDeleted: true,
      deletedAt: { lt: thirtyDaysAgo }
    }
  });

  // Permanently delete old soft-deleted items
  for (const user of deletedUsers) {
    await db.users.delete({
      where: { id: user.id, email: user.email }
    });
  }
}

Cascade Delete Pattern

When deleting a parent document, you may need to delete related documents:

async function deleteUserWithRelatedData(userId: string, email: string) {
  // Find all related posts
  const posts = await db.posts.findMany({
    partitionKey: userId
  });

  // Delete all posts
  for (const post of posts) {
    await db.posts.delete({
      where: { id: post.id, userId }
    });
  }

  // Finally, delete the user
  await db.users.delete({
    where: { id: userId, email }
  });
}

Note: CosmosDB doesn't support foreign key constraints or cascade deletes at the database level. You must handle this logic in your application code.

Bulk Operations

For deleting multiple documents at once, use deleteMany:

// Delete old posts
const result = await db.posts.deleteMany({
  where: { createdAt: { lt: oneYearAgo } },
  confirm: true, // Safety: must explicitly confirm
  partitionKey: 'user123',
  onProgress: (stats) => {
    console.log(`Deleted ${stats.updated}/${stats.total}`);
  }
});

console.log(`Deleted ${result.deleted} documents`);
console.log(`Failed: ${result.failed}`);
console.log(`RU consumed: ${result.performance.ruConsumed}`);

Key Options:

  • where: Query to match documents
  • confirm: Must be true to execute (safety requirement)
  • partitionKey or enableCrossPartitionQuery: Required (one or the other)
  • batchSize: Documents per batch (default: 50)
  • maxConcurrency: Parallel batches (default: 5)
  • continueOnError: Keep going if some fail (default: false)
  • onProgress: Progress callback with stats

Result includes:

  • deleted: Number of successfully deleted documents
  • failed: Number of failed deletions
  • errors: Array of error details
  • performance: RU consumption, duration, and throughput metrics

Best Practices:

  1. Always use confirm: true - This prevents accidental deletions
  2. Start with small batches when testing (e.g., batchSize: 10)
  3. Use continueOnError: true for large operations where some failures are acceptable
  4. Monitor progress using onProgress callbacks
  5. Use partition keys when possible (much faster than cross-partition queries)

Note: For deleting documents by specific IDs in the same partition, you can still use individual deletes:

async function deleteManyPosts(postIds: string[], userId: string) {
  const deletePromises = postIds.map(id => 
    db.posts.delete({
      where: { id, userId }
    })
  );

  await Promise.all(deletePromises);
}

However, deleteMany is more efficient for query-based deletions with built-in progress tracking and error handling.

Bulk operations are built into CosmosQL and work seamlessly with your existing containers.

Delete by Query

For query-based deletions, use deleteMany instead of manually querying and deleting:

// ✅ Recommended: Use deleteMany
const result = await db.posts.deleteMany({
  where: {
    createdAt: { lt: cutoffDate },
    isPublished: false
  },
  confirm: true,
  enableCrossPartitionQuery: true,
  onProgress: (stats) => {
    console.log(`Progress: ${stats.percentage}%`);
  }
});

This is more efficient than querying and deleting individually, as it includes built-in progress tracking, error handling, and retry logic.

Error Handling

try {
  await db.users.delete({
    where: { id: 'user_123', email: 'john@example.com' }
  });
} catch (error) {
  if (error.code === 404) {
    // Document not found
    console.error('User not found');
  } else if (error.code === 429) {
    // Rate limit exceeded
    console.error('Rate limit exceeded, please retry');
  } else {
    console.error('Failed to delete user:', error);
  }
}

Performance Considerations

1. Always Include Partition Key

// ✅ Good: Includes partition key (fast, ~5 RU)
await db.users.delete({
  where: { id: 'user_123', email: 'john@example.com' }
});

2. Use Soft Delete for Frequent Deletions

Hard deletes are expensive if done repeatedly. Soft deletes allow you to batch hard deletes later:

// Mark as deleted immediately (fast)
await db.users.update({
  where: { id: 'user_123', email: 'john@example.com' },
  data: { isDeleted: true, deletedAt: new Date() }
});

// Batch permanent deletion later (background job)
setInterval(async () => {
  await cleanupOldSoftDeletes();
}, 60 * 60 * 1000); // Run every hour

Common Patterns

Pattern: Recycle Bin

async function moveToRecycleBin(postId: string, userId: string) {
  // Copy to recycle bin
  const post = await db.posts.findUnique({
    where: { id: postId, userId }
  });

  await db.recycleBin.create({
    data: {
      id: `bin_${postId}`,
      deletedItemId: postId,
      deletedItemType: 'post',
      deletedItemData: post,
      deletedAt: new Date()
    }
  });

  // Remove from original location
  await db.posts.delete({
    where: { id: postId, userId }
  });
}

async function restoreFromRecycleBin(itemId: string) {
  const binItem = await db.recycleBin.findUnique({
    where: { id: itemId }
  });

  if (binItem.deletedItemType === 'post') {
    await db.posts.create({
      data: binItem.deletedItemData
    });
  }

  await db.recycleBin.delete({ where: { id: itemId } });
}

Next Steps

Reading Documents

Learn how to query and read documents from CosmosDB efficiently

View on separate page →

Reading data is the most common operation. CosmosQL provides three methods optimized for different scenarios.

Navigation:


Point Read (Fastest)

Use when: You know the exact ID and partition key.

Why it's fastest: Direct lookup, no scanning required.

Cost: ~1 RU (cheapest possible)

Example scenario: Loading a user's profile.

const user = await db.users.findUnique({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  }
});
// Returns: User | null

Type safety: TypeScript enforces both ID and partition key.

// ❌ This won't compile
const user = await db.users.findUnique({
  where: { id: 'user_123' }
  // Error: Property 'email' is missing
});

Query Within Partition

Use when: Filtering, sorting, or paginating within one partition.

Cost: ~3-5 RU (depends on result size)

Example scenario: Getting a user's posts.

const posts = await db.posts.findMany({
  partitionKey: 'user_123', // Required
  where: {
    isPublished: true,
    createdAt: { gte: new Date('2024-01-01') }
  },
  orderBy: { createdAt: 'desc' },
  take: 10
});
// Returns: Post[]

findMany with Aggregations

findMany can also return aggregations alongside your data - perfect for paginated lists with counts or statistics.

Example: Pagination with total count

const result = await db.posts.findMany({
  partitionKey: 'user_123',
  where: { isPublished: true },
  orderBy: { createdAt: 'desc' },
  take: 10,
  skip: 0,
  aggregate: {
    _count: true  // Count ALL matching posts
  }
});

console.log(`Showing ${result.data.length} of ${result._count} posts`);
console.log(`Page 1 of ${Math.ceil(result._count / 10)}`);
// Returns: { data: Post[], _count: number }

Example: Data with statistics

const result = await db.orders.findMany({
  partitionKey: 'customer-123',
  where: { status: 'completed' },
  take: 5,  // Last 5 orders
  orderBy: { createdAt: 'desc' },
  aggregate: {
    _count: true,
    _sum: { amount: true },
    _avg: { amount: true },
    _min: { createdAt: true },
    _max: { createdAt: true }
  }
});

// Access both data and stats
console.log(`Recent orders:`, result.data);
console.log(`Total completed orders: ${result._count}`);
console.log(`Total revenue: $${result._sum.amount}`);
console.log(`Average order value: $${result._avg.amount}`);
console.log(`Customer since: ${result._min.createdAt}`);
// Returns: { 
//   data: Order[],
//   _count: number,
//   _sum: { amount: number | null },
//   _avg: { amount: number | null },
//   _min: { createdAt: Date | null },
//   _max: { createdAt: Date | null }
// }

How it works:

  • Data query and aggregation execute in parallel
  • Both use the same where filter
  • Pagination (take/skip) only applies to data, not aggregations
  • Full type safety: TypeScript infers the complete return type

See full aggregations documentation below for more examples.

Cross-Partition Query

Use when: You need data across multiple partitions.

Cost: ~50-100+ RU (10-100x more expensive)

Requires: Explicit opt-in via enableCrossPartitionQuery: true

Example scenario: Global search across all users.

const recentUsers = await db.users.findMany({
  enableCrossPartitionQuery: true, // Must be explicit
  where: {
    createdAt: { gte: new Date('2024-01-01') }
  },
  orderBy: { createdAt: 'desc' },
  take: 20
});

When to use: Admin dashboards, analytics, reports—not user-facing queries.

Selecting Specific Fields

Use when: You don't need the full document.

Why: Reduces data transfer, faster queries, lower latency.

const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' },
  select: {
    name: true,
    email: true
  }
});
// Returns: { name: string; email: string } | null

Nested fields: Select specific fields from nested objects.

const post = await db.posts.findUnique({
  where: { id: 'post_1', userId: 'user_123' },
  select: {
    title: true,
    metadata: {
      source: true
    }
  }
});
// Returns: { title: string; metadata?: { source: string } } | null

Filtering with Where Clauses

Comprehensive filtering options:

await db.users.findMany({
  partitionKey: 'john@example.com',
  where: {
    // Exact match
    isActive: true,
    
    // Number comparisons
    age: { gt: 21 },        // Greater than
    score: { gte: 85 },     // Greater than or equal
    rating: { lt: 5 },      // Less than
    count: { lte: 100 },    // Less than or equal
    age: { gte: 18, lte: 65 }, // Range
    
    // String operations
    name: { startsWith: 'John' },    // STARTSWITH(c.name, 'John')
    email: { contains: 'gmail' },    // CONTAINS(c.email, 'gmail')
    bio: { endsWith: 'developer' },  // ENDSWITH(c.bio, 'developer')
    
    // Array operations
    tags: { contains: 'premium' },                    // CONTAINS array element
    // Note: containsAny and containsAll are planned features
    // For now, use contains for single checks or raw SQL for complex operations
    
    // Date comparisons
    createdAt: { gte: new Date('2024-01-01') },
    lastLoginAt: { lt: new Date() }
  }
});

Pagination

Implement pagination with take and skip:

const ITEMS_PER_PAGE = 20;

// Page 1
const page1 = await db.posts.findMany({
  partitionKey: 'user_123',
  take: ITEMS_PER_PAGE,
  skip: 0
});

// Page 2
const page2 = await db.posts.findMany({
  partitionKey: 'user_123',
  take: ITEMS_PER_PAGE,
  skip: ITEMS_PER_PAGE
});

Note: Large offsets can be expensive. For better performance, use cursor-based pagination with date filters.

Ordering Results

Sort results using orderBy:

// Single field ordering
await db.posts.findMany({
  partitionKey: 'user_123',
  orderBy: {
    createdAt: 'desc' // or 'asc'
  }
});

// Multiple field ordering
await db.posts.findMany({
  partitionKey: 'user_123',
  orderBy: [
    { createdAt: 'desc' },
    { title: 'asc' }
  ]
});

Raw SQL Queries

Use when: The query builder doesn't support your use case.

Example: Complex joins, CosmosDB-specific functions.

const result = await db.users.query<{ count: number }>({
  sql: `
    SELECT COUNT(1) as count 
    FROM c 
    WHERE c.isActive = @active 
    AND c.age > @minAge
  `,
  parameters: [
    { name: '@active', value: true },
    { name: '@minAge', value: 18 }
  ],
  partitionKey: 'john@example.com' // Optional: for partition-scoped query
});

Aggregations with findMany

Use when: You need both data and statistics in a single query.

Benefit: Combines data retrieval with aggregations efficiently.

findMany now supports aggregations, allowing you to fetch documents and calculate statistics (count, sum, avg, min, max) in a single query with full type safety.

Basic Example

const result = await db.orders.findMany({
  partitionKey: 'customer-123',
  where: { status: 'completed' },
  aggregate: {
    _count: true,
    _sum: { amount: true },
    _avg: { amount: true }
  }
});

console.log(`Found ${result._count} orders`);
console.log(`Total: $${result._sum.amount}`);
console.log(`Average: $${result._avg.amount}`);
console.log(`Orders:`, result.data);

Return Type Changes

Without aggregations:

const orders = await db.orders.findMany({ partitionKey: 'customer-123' });
// Type: Order[]

With aggregations:

const result = await db.orders.findMany({
  partitionKey: 'customer-123',
  aggregate: { _count: true }
});
// Type: { data: Order[], _count: number }

Pagination with Total Count

Perfect for building paginated UIs:

const result = await db.users.findMany({
  partitionKey: 'tenant-1',
  take: 20,
  skip: 0,
  aggregate: { _count: true }  // Counts ALL matching records
});

console.log(`Page 1 of ${Math.ceil(result._count / 20)}`);
console.log(`Showing ${result.data.length} of ${result._count} users`);

Available Aggregations

All aggregation operations are supported:

const result = await db.products.findMany({
  partitionKey: 'category-electronics',
  aggregate: {
    _count: true,                         // Count all records
    _sum: { price: true, stock: true },   // Sum numeric fields
    _avg: { price: true },                // Average values
    _min: { price: true, createdAt: true }, // Minimum values
    _max: { price: true, createdAt: true }  // Maximum values
  }
});

See the Aggregations Guide for complete documentation.

Performance Best Practices

1. Always Prefer Point Reads

// ✅ Good: Point read (~1 RU)
const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' }
});

// ⚠️ Slower: Query even with unique result (~5 RU)
const user = await db.users.findMany({
  partitionKey: 'john@example.com',
  where: { id: 'user_123' },
  take: 1
});

2. Use Partition Keys Efficiently

// ✅ Good: All queries scoped to partition
await db.posts.findMany({
  partitionKey: 'user_123',
  where: { isPublished: true }
});

// ❌ Bad: Cross-partition query when not needed
await db.posts.findMany({
  where: { userId: 'user_123', isPublished: true },
  enableCrossPartitionQuery: true
});

3. Limit Result Sets

// ✅ Good: Limit to what you need
await db.posts.findMany({
  partitionKey: 'user_123',
  take: 10 // Only fetch 10 results
});

4. Use Select to Reduce Data Transfer

// ✅ Good: Only fetch needed fields
await db.users.findMany({
  partitionKey: 'tenant_123',
  select: { id: true, name: true }
});

Common Query Patterns

Pattern: Getting a Count

const count = await db.posts.query<{ count: number }>({
  sql: `
    SELECT VALUE COUNT(1)
    FROM c
    WHERE c.isPublished = true
    AND c.publishedAt > @date
  `,
  parameters: [{ name: '@date', value: new Date('2024-01-01') }],
  enableCrossPartitionQuery: true
});

Pattern: Search Within Partition

const matchingPosts = await db.posts.findMany({
  partitionKey: 'user_123',
  where: {
    title: { contains: searchTerm },
    isPublished: true
  }
});

Pattern: Conditional Queries

const where: any = { isPublished: true };

if (minDate) {
  where.publishedAt = { gte: minDate };
}

if (category) {
  where.category = category;
}

const posts = await db.posts.findMany({
  partitionKey: 'user_123',
  where
});

Next Steps

Type System Deep Dive

Understanding how CosmosQL's type inference works and leveraging it effectively

View on separate page →

How Type Inference Works

CosmosQL leverages TypeScript's powerful type inference to provide compile-time safety:

const users = container('users', {
  id: field.string(),
  email: field.string(),
  name: field.string().optional(),
  age: field.number()
}).partitionKey('email');

// The type is automatically inferred:
type User = typeof users.infer;
// {
//   id: string;
//   email: string;
//   name?: string;
//   age: number;
// }

// Query results are typed automatically
const user = await db.users.findUnique({
  where: { id: '123', email: 'john@example.com' }
});
// Type: User | null (no manual type annotation needed!)

// Select creates a new type
const partial = await db.users.findUnique({
  where: { id: '123', email: 'john@example.com' },
  select: { name: true, age: true }
});
// Type: { name?: string; age: number } | null

Custom Type Errors

When you make a mistake, CosmosQL provides helpful error messages:

// Missing partition key
const user = await db.users.findUnique({
  where: { id: '123' }
});

// Error message in IDE:
// ❌ PARTITION KEY REQUIRED
//
// This operation requires a partition key to avoid expensive cross-partition queries.
//
// 💡 Fix: Add the partition key to your where clause:
//    where: { id: '123', email: 'john@example.com' }
//
// 📖 Learn more about partition keys: https://cosmosql.dev/docs/partition-keys

Type Guards

Runtime type checking when needed:

import { isCosmosError } from 'cosmosql';

try {
  await db.users.create({ data: user });
} catch (error) {
  if (isCosmosError(error)) {
    // error is now typed as CosmosError
    console.log(error.code, error.statusCode, error.retryAfter);
  }
}

Updating Documents

Learn how to update existing documents in CosmosDB

View on separate page →

CosmosQL provides flexible and type-safe ways to update documents in CosmosDB.

Navigation:


Single Update

The basic update operation modifies an existing document:

await db.users.update({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  },
  data: {
    age: 31,
    name: 'John Updated'
  }
});

Important: The partition key must be included in the where clause. This is enforced at compile time for type safety.

Partial Updates

You can update only the fields you need—other fields remain unchanged:

await db.users.update({
  where: { id: 'user_123', email: 'john@example.com' },
  data: {
    age: 31
    // Other fields remain unchanged
  }
});

This is efficient as CosmosDB only updates the specified fields, not the entire document.

Upsert Operations

The upsert operation inserts if the document doesn't exist, or updates if it does:

await db.users.upsert({
  where: {
    id: 'user_123',
    email: 'john@example.com'
  },
  create: {
    // Full user object for creation
    id: 'user_123',
    email: 'john@example.com',
    name: 'John Doe',
    age: 30,
    isActive: true,
    createdAt: new Date()
  },
  update: {
    // Partial update
    age: 31,
    name: 'John Updated'
  }
});

Why Upsert? This pattern is common in distributed systems where you want idempotent operations—operations that can be safely retried without unintended side effects.

Atomic Operations

Perform atomic increment/decrement operations:

await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: {
    viewCount: { increment: 1 },
    likeCount: { increment: 5 },
    dislikeCount: { decrement: 2 }
  }
});

This is an atomic operation performed server-side, making it safe for concurrent updates.

Update Patterns

Pattern: Touch Pattern (Update Timestamp)

await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: {
    title: 'New Title',
    updatedAt: new Date()
  }
});

Pattern: Status Transitions

// Update status with validation
const validTransitions = {
  draft: ['published', 'archived'],
  published: ['archived'],
  archived: [] // terminal state
};

function canTransition(from: string, to: string): boolean {
  return validTransitions[from]?.includes(to) ?? false;
}

async function updatePostStatus(postId: string, userId: string, newStatus: string) {
  const post = await db.posts.findUnique({
    where: { id: postId, userId }
  });

  if (!post || !canTransition(post.status, newStatus)) {
    throw new Error('Invalid status transition');
  }

  await db.posts.update({
    where: { id: postId, userId },
    data: {
      status: newStatus,
      updatedAt: new Date()
    }
  });
}

Pattern: Array Operations

While CosmosQL doesn't provide built-in array manipulation helpers, you can update entire arrays:

// Replace the entire tags array
await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: {
    tags: ['javascript', 'react', 'tutorial']
  }
});

// For append operations, fetch, modify, and update
const post = await db.posts.findUnique({
  where: { id: 'post_1', userId: 'user_123' }
});

await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: {
    tags: [...post.tags, 'new-tag']
  }
});

Bulk Operations

For updating multiple documents at once, use updateMany:

// Static update - update all inactive users
const result = await db.users.updateMany({
  where: { isActive: false },
  data: { status: 'archived' },
  partitionKey: 'user@email.com' // or enableCrossPartitionQuery: true
});

console.log(`Updated ${result.updated} documents`);
console.log(`Failed: ${result.failed}`);
console.log(`RU consumed: ${result.performance.ruConsumed}`);

Dynamic update with function:

// Update email domains for all users
const result = await db.users.updateMany({
  where: { email: { contains: '@old.com' } },
  data: (doc) => ({
    email: doc.email.replace('@old.com', '@new.com'),
    migratedAt: new Date()
  }),
  enableCrossPartitionQuery: true,
  batchSize: 50,
  maxConcurrency: 5,
  onProgress: (stats) => {
    console.log(`${stats.percentage}% - ${stats.ruConsumed} RU`);
  },
  onError: (error) => {
    console.error(`Failed: ${error.documentId}`, error.error);
  }
});

Key Options:

  • where: Query to match documents
  • data: Static object or function that returns updates
  • partitionKey or enableCrossPartitionQuery: Required (one or the other)
  • batchSize: Documents per batch (default: 50)
  • maxConcurrency: Parallel batches (default: 5)
  • continueOnError: Keep going if some fail (default: false)
  • maxRetries: Retry attempts for retriable errors (default: 3)
  • onProgress: Progress callback with stats
  • onError: Error callback for individual failures

Result includes:

  • updated: Number of successfully updated documents
  • failed: Number of failed updates
  • skipped: Number of skipped documents
  • errors: Array of error details
  • performance: RU consumption, duration, and throughput metrics

Best Practices:

  1. Start with small batches when testing (e.g., batchSize: 10)
  2. Use continueOnError: true for large operations where some failures are acceptable
  3. Monitor RU consumption using onProgress callbacks
  4. Use partition keys when possible (much faster than cross-partition queries)
  5. Test with dry runs if implementing custom logic

Bulk operations are built into CosmosQL and work seamlessly with your existing containers.

Error Handling

try {
  await db.users.update({
    where: { id: 'user_123', email: 'john@example.com' },
    data: { age: 31 }
  });
} catch (error) {
  if (error.code === 404) {
    // Document not found
    console.error('User not found');
  } else if (error.code === 429) {
    // Rate limit exceeded
    console.error('Rate limit exceeded, please retry');
  } else if (error.code === 412) {
    // Precondition failed (e.g., ETag mismatch)
    console.error('Document was modified, please retry');
  } else {
    console.error('Failed to update user:', error);
  }
}

Performance Considerations

1. Use Partial Updates

// ✅ Good: Only update what changed
await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: { title: 'New Title' }
});

2. Batch Related Updates

// ✅ Good: Single atomic operation
await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: {
    viewCount: { increment: 1 },
    lastViewedAt: new Date()
  }
});

Next Steps

Aggregations

Complete guide to using aggregation functions in CosmosQL - count, sum, average, min, max, and group by operations with full type safety.

View on separate page →

Complete guide to using aggregation functions in CosmosQL - count, sum, average, min, max, and group by operations with full type safety.

Table of Contents


Overview

CosmosQL provides a complete set of aggregation functions that are:

  • Fully type-safe - TypeScript infers exact result types
  • SQL-compatible - Translates to efficient Cosmos DB SQL
  • Partition-aware - Optimized for Cosmos DB's partitioning
  • Composable - Combine multiple aggregations in one query

Available Aggregations

OperationDescriptionWorks With
_countCount documentsAll fields
_sumSum valuesNumber fields
_avgAverage valuesNumber fields
_minFind minimumAll fields
_maxFind maximumAll fields

Count Operations

Basic Count

Count documents in a partition:

import { createClient, container, field } from 'cosmosql';

const orderSchema = container('orders', {
  id: field.string(),
  customerId: field.string(),
  amount: field.number(),
  status: field.string(),
}).partitionKey('customerId');

const client = createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'mydb',
});

const db = await client.withContainers({ orders: orderSchema });

// Count orders for a customer
const count = await db.orders.count({
  partitionKey: 'customer-123',
});
// Result: 42

Count with Filters

Add WHERE conditions to count specific documents:

// Count only completed orders
const completedCount = await db.orders.count({
  partitionKey: 'customer-123',
  where: {
    status: 'completed',
  },
});

// Count with complex filters
const recentHighValueCount = await db.orders.count({
  partitionKey: 'customer-123',
  where: {
    status: 'completed',
    amount: { gte: 100 },
    createdAt: { gte: new Date('2024-01-01') },
  },
});

Cross-Partition Count

⚠️ Use sparingly - Cross-partition queries are expensive in Cosmos DB.

// Count ALL orders across all customers
const totalOrders = await db.orders.count({
  enableCrossPartitionQuery: true,
});

// Cross-partition count with filter
const allCompletedOrders = await db.orders.count({
  enableCrossPartitionQuery: true,
  where: { status: 'completed' },
});

Count Specific Fields

Count non-null values in specific fields:

const result = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _count: {
    select: {
      amount: true,        // Count orders with amount
      discount: true,      // Count orders with discount (might be optional)
      trackingNumber: true,
    },
  },
});

// Result type is inferred:
// {
//   _count: {
//     amount: number;
//     discount: number;
//     trackingNumber: number;
//   }
// }

Aggregate Operations

Single Aggregation

// Sum all order amounts for a customer
const result = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _sum: {
    amount: true,
  },
});

// Result: { _sum: { amount: 1250.50 | null } }

Multiple Aggregations

Combine multiple operations in one query (more efficient than separate queries):

const stats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _count: true,                    // Total count
  _sum: { amount: true },          // Total revenue
  _avg: { amount: true },          // Average order value
  _min: { amount: true },          // Smallest order
  _max: { amount: true },          // Largest order
});

// Result type (fully inferred):
// {
//   _count: number;
//   _sum: { amount: number | null };
//   _avg: { amount: number | null };
//   _min: { amount: number | null };
//   _max: { amount: number | null };
// }

console.log(`
  Orders: ${stats._count}
  Total Revenue: $${stats._sum.amount}
  Average Order: $${stats._avg.amount}
  Range: $${stats._min.amount} - $${stats._max.amount}
`);

Aggregations with Filters

// Stats for completed orders only
const completedStats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  where: { status: 'completed' },
  _count: true,
  _sum: { amount: true },
  _avg: { amount: true },
});

// Year-to-date statistics
const ytdStats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  where: {
    createdAt: { gte: new Date('2024-01-01') },
  },
  _sum: { amount: true, tax: true, shipping: true },
  _count: true,
});

Multiple Field Aggregations

const productSchema = container('products', {
  id: field.string(),
  category: field.string(),
  price: field.number(),
  cost: field.number(),
  stock: field.number(),
  weight: field.number(),
}).partitionKey('category');

const stats = await db.products.aggregate({
  partitionKey: 'electronics',
  _sum: {
    stock: true,    // Total inventory
    price: true,    // Total value at retail
    cost: true,     // Total cost basis
  },
  _avg: {
    price: true,    // Average price
    weight: true,   // Average weight
  },
  _min: {
    price: true,    // Cheapest product
  },
  _max: {
    price: true,    // Most expensive product
  },
});

// Calculate profit margin
const totalProfit = stats._sum.price! - stats._sum.cost!;
const margin = (totalProfit / stats._sum.price!) * 100;
console.log(`Profit margin: ${margin.toFixed(2)}%`);

findMany with Aggregations

The findMany method supports aggregations, allowing you to fetch data and calculate statistics in a single, efficient query. This combines the power of data retrieval with aggregation operations.

Return Types

Without Aggregations (Backward Compatible):

// No aggregate parameter
const users = await db.users.findMany({ partitionKey: "john@example.com" });
// Type: User[]

const selectedUsers = await db.users.findMany({ 
  partitionKey: "john@example.com",
  select: { id: true, name: true }
});
// Type: Array<{ id: string, name: string }>

With Aggregations:

// With aggregate parameter
const result = await db.users.findMany({
  partitionKey: "john@example.com",
  aggregate: { _count: true }
});
// Type: { data: User[], _count: number }

Basic Usage

const result = await db.users.findMany({
  partitionKey: "john@example.com",
  where: { isActive: true },
  aggregate: {
    _count: true,
    _avg: { age: true }
  }
});

console.log(`Found ${result._count} active users`);
console.log(`Average age: ${result._avg.age}`);
console.log(`Users:`, result.data);

Multiple Aggregations

Combine data retrieval with comprehensive statistics:

const stats = await db.orders.findMany({
  partitionKey: "customer-123",
  aggregate: {
    _count: true,
    _sum: { totalAmount: true },
    _avg: { totalAmount: true },
    _min: { createdAt: true },
    _max: { createdAt: true }
  }
});

console.log(`Order Statistics:`);
console.log(`  Total orders: ${stats._count}`);
console.log(`  Total revenue: ${stats._sum.totalAmount}`);
console.log(`  Average order: ${stats._avg.totalAmount}`);
console.log(`  First order: ${stats._min.createdAt}`);
console.log(`  Latest order: ${stats._max.createdAt}`);
console.log(`  Orders: ${stats.data.length} items`);

With Filtering and Selection

const result = await db.products.findMany({
  partitionKey: "category-electronics",
  where: { 
    price: { gte: 100 },
    inStock: true 
  },
  select: { id: true, name: true, price: true },
  aggregate: {
    _count: true,
    _avg: { price: true },
    _min: { price: true },
    _max: { price: true }
  }
});

// Only selected fields in data
console.log(result.data); // Array<{ id: string, name: string, price: number }>

// All aggregations available
console.log(`Expensive products: ${result._count}`);
console.log(`Price range: ${result._min.price} - ${result._max.price}`);

Paginated Results with Total Count

Perfect for building paginated UIs with accurate page counts:

const result = await db.users.findMany({
  partitionKey: "john@example.com",
  take: 10,  // Get first 10 users
  skip: 0,
  aggregate: {
    _count: true,  // Count ALL users matching query
  }
});

console.log(`Showing ${result.data.length} of ${result._count} total users`);

// Calculate total pages
const totalPages = Math.ceil(result._count / 10);
console.log(`Page 1 of ${totalPages}`);

Cross-Partition Queries

const result = await db.products.findMany({
  where: { category: "electronics" },
  enableCrossPartitionQuery: true,
  aggregate: {
    _count: true,
    _avg: { price: true }
  }
});

console.log(`Electronics across all partitions:`);
console.log(`  Total products: ${result._count}`);
console.log(`  Average price: ${result._avg.price}`);

Behavior & Performance

Parallel Execution:

  • Data query and aggregation query execute in parallel
  • Both queries use the same where, partitionKey, and enableCrossPartitionQuery filters
  • Results are combined into a single response object

Filtering Rules:

  • where clause: Applied to both data and aggregation queries
  • orderBy, take, skip: Applied only to data query (aggregations count all matching records)
  • select: Applied only to data query (aggregations work on full records)

Comparison with Separate Methods

// Before: Two separate queries
const users = await db.users.findMany({ partitionKey: "john@example.com" });
const stats = await db.users.aggregate({
  partitionKey: "john@example.com",
  _count: true,
  _avg: { age: true }
});

// After: Single efficient query
const result = await db.users.findMany({
  partitionKey: "john@example.com",
  aggregate: {
    _count: true,
    _avg: { age: true }
  }
});
// result.data contains users
// result._count and result._avg contain aggregations

When to Use Each Approach:

Use findMany with aggregate:

  • When you need both data and aggregations
  • For paginated results with total counts
  • When filtering is shared between data and aggregations

Use separate aggregate() method:

  • When you only need aggregations (no data)
  • For complex group-by operations
  • When data and aggregations have different filters

All Aggregation Operations

Count Records:

const result = await db.users.findMany({
  partitionKey: "john@example.com",
  aggregate: { _count: true }
});
// result._count: number

// Count specific fields (non-null values)
const result = await db.users.findMany({
  partitionKey: "john@example.com",
  aggregate: {
    _count: { select: { name: true, email: true } }
  }
});
// result._count: { name: number, email: number }

Sum, Average, Min, Max:

const result = await db.orders.findMany({
  partitionKey: "customer-123",
  aggregate: {
    _sum: { amount: true, tax: true },
    _avg: { amount: true },
    _min: { amount: true, createdAt: true },
    _max: { amount: true, createdAt: true }
  }
});

// All typed correctly based on field types
// number fields return number | null
// date fields return Date | null

Full TypeScript Support

const result = await db.users.findMany({
  partitionKey: "john@example.com",
  aggregate: {
    _count: true,
    _avg: { age: true },
    _sum: { score: true }
  }
});

// Type: { 
//   data: User[], 
//   _count: number,
//   _avg: { age: number | null },
//   _sum: { score: number | null }
// }

Group By Operations

Group By is the most powerful aggregation feature - it splits data into groups and performs aggregations on each group.

Group by Single Field

const salesSchema = container('sales', {
  id: field.string(),
  storeId: field.string(),
  category: field.string(),
  amount: field.number(),
  quantity: field.number(),
}).partitionKey('storeId');

// Sales by category
const categoryStats = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _count: true,
  _sum: { amount: true, quantity: true },
  _avg: { amount: true },
});

// Result type (fully inferred):
// Array<{
//   category: string;
//   _count: number;
//   _sum: { amount: number | null; quantity: number | null };
//   _avg: { amount: number | null };
// }>

// Use the results
for (const group of categoryStats) {
  console.log(`${group.category}:
    Sales: $${group._sum.amount}
    Units: ${group._sum.quantity}
    Avg: $${group._avg.amount}
  `);
}

Group by Multiple Fields

Use an array to group by multiple fields:

// Sales by store AND category
const detailedStats = await db.sales.groupBy({
  by: ['storeId', 'category'] as const,  // 'as const' for better type inference
  enableCrossPartitionQuery: true,
  _count: true,
  _sum: { amount: true },
});

// Result type:
// Array<{
//   storeId: string;
//   category: string;
//   _count: number;
//   _sum: { amount: number | null };
// }>

// Find best performing store-category combination
const topPerformer = detailedStats.reduce((max, group) => 
  (group._sum.amount || 0) > (max._sum.amount || 0) ? group : max
);

console.log(`Top: ${topPerformer.storeId} - ${topPerformer.category}`);

Group By with Ordering

Sort results by aggregation values:

// Top categories by revenue
const topCategories = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _count: true,
  _sum: { amount: true },
  orderBy: {
    _sum_amount: 'desc',  // Order by aggregated field
  },
  take: 10,  // Top 10
});

// Categories with most transactions
const popularCategories = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _count: true,
  orderBy: {
    _count: 'desc',
  },
  take: 5,
});

Group By with Pagination

// Paginated category report
const page1 = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _sum: { amount: true },
  orderBy: { category: 'asc' },
  take: 20,
  skip: 0,
});

const page2 = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _sum: { amount: true },
  orderBy: { category: 'asc' },
  take: 20,
  skip: 20,
});

Group By with Filters

Combine WHERE clauses with GROUP BY:

const userSchema = container('analytics', {
  id: field.string(),
  userId: field.string(),
  eventType: field.string(),
  country: field.string(),
  timestamp: field.date(),
  duration: field.number(),
}).partitionKey('userId');

// Events by type for a specific user in the last 30 days
const recentEvents = await db.analytics.groupBy({
  by: 'eventType',
  partitionKey: 'user-123',
  where: {
    timestamp: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
  },
  _count: true,
  _avg: { duration: true },
});

// Cross-partition: Events by country for premium users
const countryStats = await db.analytics.groupBy({
  by: 'country',
  enableCrossPartitionQuery: true,
  where: {
    eventType: 'purchase',
  },
  _count: true,
  _sum: { duration: true },
});

Convenience Methods

For simple single-field aggregations, use convenience methods:

sum()

// Quick sum - cleaner than aggregate()
const totalRevenue = await db.orders.sum('amount', {
  partitionKey: 'customer-123',
});
// Returns: number | null

// With filter
const completedRevenue = await db.orders.sum('amount', {
  partitionKey: 'customer-123',
  where: { status: 'completed' },
});

avg()

// Average order value
const avgOrderValue = await db.orders.avg('amount', {
  partitionKey: 'customer-123',
});
// Returns: number | null

// Average rating for a product
const avgRating = await db.reviews.avg('rating', {
  partitionKey: 'product-456',
  where: { verified: true },
});

min()

// Find cheapest product
const cheapestPrice = await db.products.min('price', {
  partitionKey: 'electronics',
});
// Returns: number | null

// Earliest order date
const firstOrder = await db.orders.min('createdAt', {
  partitionKey: 'customer-123',
});
// Returns: Date | null (type inferred from field!)

max()

// Most expensive product
const highestPrice = await db.products.max('price', {
  partitionKey: 'electronics',
});

// Latest activity
const lastSeen = await db.users.max('lastLoginAt', {
  partitionKey: 'tenant-1',
});
// Returns: Date | null

Type Safety

CosmosQL provides complete type inference for all aggregation operations.

Automatic Type Inference

const orderSchema = container('orders', {
  id: field.string(),
  customerId: field.string(),
  amount: field.number(),
  tax: field.number().optional(),
  status: field.string(),
  createdAt: field.date(),
}).partitionKey('customerId');

// TypeScript knows exact types
const stats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _count: true,
  _sum: { amount: true, tax: true },
  _min: { createdAt: true },
  _max: { createdAt: true },
});

// ✅ All these are correctly typed:
const count: number = stats._count;
const total: number | null = stats._sum.amount;
const taxTotal: number | null = stats._sum.tax;
const firstOrder: Date | null = stats._min.createdAt;
const lastOrder: Date | null = stats._max.createdAt;

// ❌ TypeScript errors:
// stats._sum.status;  // Error: 'status' is not a number field
// stats._avg.createdAt;  // Error: 'createdAt' is not a number field

Group By Type Inference

// Single field grouping
const result1 = await db.orders.groupBy({
  by: 'status',
  enableCrossPartitionQuery: true,
  _count: true,
  _sum: { amount: true },
});
// Type: Array<{ status: string; _count: number; _sum: { amount: number | null } }>

// Multiple field grouping
const result2 = await db.orders.groupBy({
  by: ['customerId', 'status'] as const,
  enableCrossPartitionQuery: true,
  _count: true,
});
// Type: Array<{ customerId: string; status: string; _count: number }>

// Access fields with full type safety
for (const group of result1) {
  console.log(group.status);      // ✅ string
  console.log(group._count);      // ✅ number
  console.log(group._sum.amount); // ✅ number | null
}

Optional Fields

const productSchema = container('products', {
  id: field.string(),
  category: field.string(),
  price: field.number(),
  salePrice: field.number().optional(),  // Optional field
}).partitionKey('category');

// TypeScript knows salePrice might be undefined
const result = await db.products.aggregate({
  partitionKey: 'electronics',
  _sum: { price: true, salePrice: true },  // ✅ Works with optional fields
  _avg: { salePrice: true },
});

// All aggregation results are nullable
const total: number | null = result._sum.price;
const saleTotal: number | null = result._sum.salePrice;

Type-Safe Field Selection

// Only number fields can be summed/averaged
await db.orders.aggregate({
  partitionKey: 'customer-123',
  _sum: {
    amount: true,    // ✅ number field
    // status: true,  // ❌ Error: 'status' is not a number
  },
  _avg: {
    amount: true,    // ✅ number field
    // createdAt: true,  // ❌ Error: dates can't be averaged
  },
});

// Any field can be used with min/max/count
await db.orders.aggregate({
  partitionKey: 'customer-123',
  _min: {
    amount: true,     // ✅ number
    status: true,     // ✅ string
    createdAt: true,  // ✅ date
  },
});

Real-World Examples

E-Commerce Analytics Dashboard

// Schema
const orderSchema = container('orders', {
  id: field.string(),
  customerId: field.string(),
  status: field.string(),
  totalAmount: field.number(),
  itemCount: field.number(),
  discount: field.number().optional(),
  createdAt: field.date(),
}).partitionKey('customerId');

// Customer lifetime value
async function getCustomerLTV(customerId: string) {
  const stats = await db.orders.aggregate({
    partitionKey: customerId,
    where: { status: 'completed' },
    _count: true,
    _sum: { totalAmount: true, itemCount: true, discount: true },
    _avg: { totalAmount: true },
    _min: { createdAt: true },
    _max: { createdAt: true },
  });

  return {
    totalOrders: stats._count,
    lifetimeValue: stats._sum.totalAmount || 0,
    totalItems: stats._sum.itemCount || 0,
    totalSavings: stats._sum.discount || 0,
    avgOrderValue: stats._avg.totalAmount || 0,
    firstOrderDate: stats._min.createdAt,
    lastOrderDate: stats._max.createdAt,
    customerSince: stats._min.createdAt 
      ? Math.floor((Date.now() - stats._min.createdAt.getTime()) / (1000 * 60 * 60 * 24))
      : 0,
  };
}

// Sales by status report
async function getSalesStatusReport() {
  const statusReport = await db.orders.groupBy({
    by: 'status',
    enableCrossPartitionQuery: true,
    _count: true,
    _sum: { totalAmount: true, itemCount: true },
    _avg: { totalAmount: true },
  });

  return statusReport.map(group => ({
    status: group.status,
    count: group._count,
    revenue: group._sum.totalAmount || 0,
    items: group._sum.itemCount || 0,
    avgValue: group._avg.totalAmount || 0,
  }));
}

// Top customers
async function getTopCustomers(limit: number = 10) {
  const customers = await db.orders.groupBy({
    by: 'customerId',
    enableCrossPartitionQuery: true,
    _count: true,
    _sum: { totalAmount: true },
    orderBy: { _sum_totalAmount: 'desc' },
    take: limit,
  });

  return customers.map(c => ({
    customerId: c.customerId,
    orders: c._count,
    spent: c._sum.totalAmount || 0,
  }));
}

Analytics Platform

const eventSchema = container('events', {
  id: field.string(),
  userId: field.string(),
  eventType: field.string(),
  page: field.string(),
  duration: field.number(),
  timestamp: field.date(),
}).partitionKey('userId');

// User engagement metrics
async function getUserEngagement(userId: string, days: number = 30) {
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
  
  const metrics = await db.events.aggregate({
    partitionKey: userId,
    where: { timestamp: { gte: since } },
    _count: true,
    _sum: { duration: true },
    _avg: { duration: true },
  });

  const eventBreakdown = await db.events.groupBy({
    by: 'eventType',
    partitionKey: userId,
    where: { timestamp: { gte: since } },
    _count: true,
    _avg: { duration: true },
  });

  return {
    totalEvents: metrics._count,
    totalTime: metrics._sum.duration || 0,
    avgSessionTime: metrics._avg.duration || 0,
    eventTypes: eventBreakdown,
  };
}

// Platform-wide statistics
async function getPlatformStats() {
  // Events by type
  const eventTypes = await db.events.groupBy({
    by: 'eventType',
    enableCrossPartitionQuery: true,
    where: {
      timestamp: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
    },
    _count: true,
    _avg: { duration: true },
    orderBy: { _count: 'desc' },
  });

  // Page engagement
  const pages = await db.events.groupBy({
    by: 'page',
    enableCrossPartitionQuery: true,
    where: {
      timestamp: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
    },
    _count: true,
    _sum: { duration: true },
    orderBy: { _count: 'desc' },
    take: 10,
  });

  return { eventTypes, topPages: pages };
}

Inventory Management

const productSchema = container('products', {
  id: field.string(),
  warehouseId: field.string(),
  category: field.string(),
  stock: field.number(),
  reorderPoint: field.number(),
  cost: field.number(),
  price: field.number(),
}).partitionKey('warehouseId');

// Warehouse inventory summary
async function getWarehouseSummary(warehouseId: string) {
  const summary = await db.products.aggregate({
    partitionKey: warehouseId,
    _count: true,
    _sum: { stock: true, cost: true, price: true },
  });

  const categoryBreakdown = await db.products.groupBy({
    by: 'category',
    partitionKey: warehouseId,
    _count: true,
    _sum: { stock: true },
    _avg: { price: true },
  });

  const inventoryValue = (summary._sum.cost || 0) * (summary._sum.stock || 0);
  const retailValue = (summary._sum.price || 0) * (summary._sum.stock || 0);

  return {
    totalProducts: summary._count,
    totalStock: summary._sum.stock || 0,
    inventoryValue,
    retailValue,
    potentialProfit: retailValue - inventoryValue,
    byCategory: categoryBreakdown,
  };
}

// Low stock alerts
async function getLowStockItems(warehouseId: string) {
  const items = await db.products.findMany({
    partitionKey: warehouseId,
    where: {
      stock: { lte: field.number() }, // Stock <= reorderPoint
    },
  });

  // Get summary stats
  const stats = await db.products.aggregate({
    partitionKey: warehouseId,
    where: {
      stock: { lte: field.number() },
    },
    _count: true,
    _sum: { stock: true },
  });

  return {
    items,
    totalLowStockItems: stats._count,
    totalLowStockUnits: stats._sum.stock || 0,
  };
}

Performance & Best Practices

✅ DO: Use Partition Keys

Always provide a partition key when possible:

// ✅ GOOD - Single partition query (fast & cheap)
const count = await db.orders.count({
  partitionKey: 'customer-123',
});

// ⚠️ AVOID - Cross-partition query (slow & expensive)
const count = await db.orders.count({
  enableCrossPartitionQuery: true,
});

✅ DO: Combine Multiple Aggregations

More efficient to get multiple aggregations in one query:

// ✅ GOOD - Single query
const stats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _count: true,
  _sum: { amount: true },
  _avg: { amount: true },
});

// ❌ BAD - Three separate queries
const count = await db.orders.count({ partitionKey: 'customer-123' });
const sum = await db.orders.sum('amount', { partitionKey: 'customer-123' });
const avg = await db.orders.avg('amount', { partitionKey: 'customer-123' });

✅ DO: Use WHERE Clauses

Filter data to reduce computation:

// ✅ GOOD - Only aggregate what you need
const completedStats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  where: { status: 'completed' },
  _sum: { amount: true },
});

// ⚠️ AVOID - Aggregating everything then filtering
const allStats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _sum: { amount: true },
});
// Then filtering in application code

✅ DO: Use Pagination for Large Results

// ✅ GOOD - Paginate large group by results
async function* getAllGroups() {
  let skip = 0;
  const pageSize = 100;
  
  while (true) {
    const page = await db.sales.groupBy({
      by: 'category',
      enableCrossPartitionQuery: true,
      _sum: { amount: true },
      take: pageSize,
      skip,
    });
    
    if (page.length === 0) break;
    yield* page;
    skip += pageSize;
  }
}

⚠️ AVOID: Unnecessary Cross-Partition Queries

// ❌ BAD - Expensive cross-partition query
const total = await db.orders.sum('amount', {
  enableCrossPartitionQuery: true,
});

// ✅ BETTER - Store aggregates separately if needed frequently
// Use a separate container or materialized views

⚠️ AVOID: Aggregating Large Date Ranges Without Filters

// ❌ BAD - Scanning entire history
const allTimeStats = await db.orders.aggregate({
  enableCrossPartitionQuery: true,
  _sum: { amount: true },
});

// ✅ BETTER - Limit to relevant time period
const recentStats = await db.orders.aggregate({
  enableCrossPartitionQuery: true,
  where: {
    createdAt: { gte: new Date('2024-01-01') },
  },
  _sum: { amount: true },
});

Understanding NULL Results

Aggregations return null when there are no rows:

const stats = await db.orders.aggregate({
  partitionKey: 'new-customer',
  _sum: { amount: true },
  _avg: { amount: true },
});

// stats._sum.amount might be null (no orders)
// Always handle null case:
const total = stats._sum.amount ?? 0;
const average = stats._avg.amount ?? 0;

// Or use nullish coalescing in display:
console.log(`Total: $${stats._sum.amount || 0}`);

Cosmos DB RU Costs

Aggregation operations consume Request Units (RUs):

  • Single Partition: ~2-5 RUs (very efficient)
  • Cross-Partition: 5-100+ RUs depending on data size
  • Group By: Higher RU cost, especially cross-partition

Monitor your RU consumption and optimize queries accordingly.


Advanced Patterns

Combining Aggregations with Find

// Get both detail and summary
async function getOrderReport(customerId: string) {
  const [orders, stats] = await Promise.all([
    db.orders.findMany({
      partitionKey: customerId,
      orderBy: { createdAt: 'desc' },
      take: 10,
    }),
    db.orders.aggregate({
      partitionKey: customerId,
      _count: true,
      _sum: { amount: true },
      _avg: { amount: true },
    }),
  ]);

  return { recentOrders: orders, summary: stats };
}

Calculating Percentages

const statusBreakdown = await db.orders.groupBy({
  by: 'status',
  enableCrossPartitionQuery: true,
  _count: true,
});

const total = statusBreakdown.reduce((sum, g) => sum + g._count, 0);

const withPercentages = statusBreakdown.map(group => ({
  status: group.status,
  count: group._count,
  percentage: ((group._count / total) * 100).toFixed(2) + '%',
}));

Time-Series Aggregations

// Daily sales for last 30 days
async function getDailySales(days: number = 30) {
  const results = [];
  
  for (let i = 0; i < days; i++) {
    const date = new Date();
    date.setDate(date.getDate() - i);
    const startOfDay = new Date(date.setHours(0, 0, 0, 0));
    const endOfDay = new Date(date.setHours(23, 59, 59, 999));

    const stats = await db.orders.aggregate({
      enableCrossPartitionQuery: true,
      where: {
        createdAt: { gte: startOfDay, lte: endOfDay },
        status: 'completed',
      },
      _count: true,
      _sum: { amount: true },
    });

    results.push({
      date: startOfDay,
      orders: stats._count,
      revenue: stats._sum.amount || 0,
    });
  }

  return results;
}

Error Handling

import { CosmosError } from 'cosmosql';

try {
  const stats = await db.orders.aggregate({
    // partitionKey missing!
    _sum: { amount: true },
  });
} catch (error) {
  if (error instanceof CosmosError) {
    if (error.statusCode === 429) {
      // Rate limited - retry with backoff
      console.log('Rate limited, retry after:', error.retryAfter);
    } else if (error.code === 'PARTITION_KEY_REQUIRED') {
      // Missing partition key
      console.error('Must provide partition key or enable cross-partition query');
    }
  }
  throw error;
}

Migration from Raw Cosmos DB

If you're migrating from raw Cosmos DB SDK:

Before (Raw SDK)

const querySpec = {
  query: 'SELECT COUNT(1) as count, SUM(c.amount) as total FROM c WHERE c.customerId = @customerId',
  parameters: [{ name: '@customerId', value: 'customer-123' }],
};

const { resources } = await container.items.query(querySpec).fetchAll();
const result = resources[0];

After (CosmosQL)

const result = await db.orders.aggregate({
  partitionKey: 'customer-123',
  _count: true,
  _sum: { amount: true },
});
// Result is fully typed!

Summary

CosmosQL's aggregation functions provide:

  1. Type Safety - Full TypeScript inference for all operations
  2. Clean API - Intuitive, composable operations
  3. Performance - Optimized queries with partition awareness
  4. Flexibility - From simple counts to complex group by operations
  5. Developer Experience - IntelliSense, autocompletion, and compile-time checks

Start with simple count() operations, then explore aggregate() for multiple operations, and finally groupBy() for advanced analytics. Always consider partition keys and cross-partition costs when designing your queries.

For more examples, see the API reference or join our community for support.

Creating Documents

Learn how to create and insert documents into CosmosDB with CosmosQL

View on separate page →

Complete guide to inserting documents into CosmosDB.

Navigation:


Single Document

Use when: Creating one document at a time.

Example scenario: User registration.

const newUser = await db.users.create({
  data: {
    id: 'user_123',
    email: 'john@example.com',
    name: 'John Doe',
    age: 30,
    createdAt: new Date()
    // Optional fields with defaults are automatically applied
  }
});
// Returns: Fully typed User object

What happens:

  1. CosmosQL generates a POST request to CosmosDB REST API
  2. Document is stored in the partition determined by email
  3. Return value is fully typed based on your schema

Cost: ~5 RU per document

Default Values

Use when: Your schema defines defaults for optional fields.

const users = container('users', {
  id: field.string(),
  email: field.string(),
  isActive: field.boolean().default(true), // Default value
  createdAt: field.date()
}).partitionKey('email');

// No need to provide isActive
const user = await db.users.create({
  data: {
    id: 'user_123',
    email: 'john@example.com',
    createdAt: new Date()
    // isActive automatically true
  }
});

Bulk Operations

Use when: Creating multiple documents in one request.

Requirement: All documents MUST share the same partition key.

Why: CosmosDB batch operations only work within a single partition.

Example scenario: Importing user posts.

await db.posts.createMany({
  data: [
    { id: 'post_1', userId: 'user_123', title: 'First', content: '...' },
    { id: 'post_2', userId: 'user_123', title: 'Second', content: '...' },
    { id: 'post_3', userId: 'user_123', title: 'Third', content: '...' }
  ],
  partitionKey: 'user_123' // Must be same for all documents
});

Cost: ~5 RU per document (same as single creates)
Performance: Single HTTP request vs multiple

Error handling: If any document fails, none are created (atomic within partition).

Best Practices

1. Always Provide IDs Explicitly

// ✅ Good: Explicit ID
const user = await db.users.create({
  data: {
    id: `user_${Date.now()}`, // Predictable
    email: 'user@example.com',
    // ...
  }
});

2. Use Meaningful IDs

// ✅ Good: Human-readable
'user_john@example.com'
'order_2024-01-15-abc123'

// ❌ Bad: Random UUIDs (unless you need them)
'a3f5b8c2-d4e6-7890-abcd-ef1234567890'

3. Set Timestamps Explicitly

const user = await db.users.create({
  data: {
    id: 'user_123',
    email: 'john@example.com',
    createdAt: new Date(), // Server time
    updatedAt: new Date()
  }
});

Error Handling

try {
  const user = await db.users.create({
    data: {
      id: 'user_123',
      email: 'john@example.com',
      // ...
    }
  });
} catch (error) {
  if (error.code === 409) {
    // Document with this ID already exists
    console.error('User already exists');
  } else if (error.code === 429) {
    // Rate limit exceeded
    console.error('Rate limit exceeded, please retry');
  } else {
    console.error('Failed to create user:', error);
  }
}

Common Patterns

Pattern: Multi-Tenant Applications

// Partition by tenant ID
const posts = container('posts', {
  id: field.string(),
  tenantId: field.string(),
  title: field.string(),
  // ...
}).partitionKey('tenantId');

// Create posts scoped to a tenant
await db.posts.createMany({
  data: [
    { id: 'post_1', tenantId: 'tenant_abc', title: '...' },
    { id: 'post_2', tenantId: 'tenant_abc', title: '...' }
  ],
  partitionKey: 'tenant_abc'
});

Pattern: Hierarchical Data

// Create parent-child relationships
const category = await db.categories.create({
  data: {
    id: 'cat_tech',
    name: 'Technology',
    parentId: null,
    createdAt: new Date()
  }
});

const subcategory = await db.categories.create({
  data: {
    id: 'cat_tech_ai',
    name: 'Artificial Intelligence',
    parentId: 'cat_tech',
    createdAt: new Date()
  }
});

Next Steps

CRUD Operations

Overview of Create, Read, Update, and Delete operations in CosmosQL

View on separate page →

CosmosQL provides a complete set of CRUD (Create, Read, Update, Delete) operations with full type safety. This page provides an overview of all operations.

Create Operations

  • Creating Documents - Create single and multiple documents
  • create() - Create a single document
  • createMany() - Create multiple documents in one operation

Read Operations

  • Reading Documents - Query and retrieve documents
  • findUnique() - Get a single document by ID + partition key (fastest: 1 RU)
  • findMany() - Query multiple documents with filtering, sorting, and pagination
  • query() - Execute raw SQL queries for advanced use cases

Update Operations

  • Updating Documents - Modify existing documents
  • update() - Update a single document
  • updateMany() - Update multiple documents matching criteria
  • upsert() - Update if exists, create if doesn't

Delete Operations

  • Deleting Documents - Remove documents
  • delete() - Delete a single document
  • deleteMany() - Delete multiple documents matching criteria

Common Patterns

All operations follow consistent patterns:

Partition Key Requirement

Most operations require a partition key to ensure efficient queries:

// ✅ Good: Partition-scoped operation
const user = await db.users.findUnique({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  }
});

// ⚠️ Expensive: Cross-partition (must explicitly opt-in)
const users = await db.users.findMany({
  enableCrossPartitionQuery: true,
  where: { isActive: true }
});

Type Safety

All operations are fully typed:

// TypeScript knows the exact return type
const user = await db.users.create({
  data: {
    id: 'user_123',
    email: 'john@example.com',
    name: 'John Doe',
    age: 30
  }
});
// user is typed as: { id: string; email: string; name: string; age: number; ... }

Error Handling

All operations can throw CosmosError:

import { isCosmosError } from 'cosmosql';

try {
  await db.users.create({ data: user });
} catch (error) {
  if (isCosmosError(error)) {
    // Handle CosmosDB-specific errors
    if (error.statusCode === 409) {
      // Duplicate ID
    }
  }
}

See Error Handling for complete error handling guide.

Next Steps

Database Management

Comprehensive tools for managing and monitoring your CosmosDB resources

View on separate page →

Manage and monitor your CosmosDB database with built-in tools for health checks, schema validation, and container management.

Navigation:


Database Information

Get detailed information about your database:

const info = await db.management.getDatabaseInfo();

console.log(`Database: ${info.id}`);
console.log(`Containers: ${info.containersCount}`);
console.log(`Total documents: ${info.storage.totalDocuments}`);
console.log(`Storage: ${info.storage.totalSizeGB} GB`);

// Container details
info.containers.forEach(c => {
  console.log(`\n${c.id}:`);
  console.log(`  Documents: ${c.statistics.documentCount}`);
  console.log(`  Partition Key: ${c.partitionKey.paths[0]}`);
  console.log(`  Registered: ${c.schema?.registered}`);
});

Health Checks

Run health checks on your database:

const health = await db.management.healthCheck();

console.log(`Overall Health: ${health.overallHealth}`); // 'healthy', 'warning', or 'critical'

health.containers.forEach(c => {
  if (!c.healthy) {
    console.log(`⚠️  ${c.container}:`);
    c.issues.forEach(issue => {
      console.log(`  [${issue.severity}] ${issue.message}`);
      if (issue.recommendation) {
        console.log(`  💡 ${issue.recommendation}`);
      }
    });
  }
});

// Recommendations
health.recommendations.forEach(r => console.log(`💡 ${r}`));

Issue Types:

  • missing_index: Container has very few indexed paths
  • large_documents: Average document size is high
  • orphaned: Container not registered in schema

Schema Diff

Compare your schema with the actual database:

const diff = await db.management.diffSchema();

if (diff.requiresAction) {
  console.log('⚠️  Schema drift detected!');
  
  // Containers in database but not schema
  if (diff.containers.orphaned.length > 0) {
    console.log(`Orphaned: ${diff.containers.orphaned.join(', ')}`);
  }
  
  // Containers in schema but not database
  if (diff.containers.missing.length > 0) {
    console.log(`Missing: ${diff.containers.missing.join(', ')}`);
  }
  
  // Configuration differences
  diff.containers.modified.forEach(mod => {
    console.log(`${mod.container} has differences:`);
    console.log(JSON.stringify(mod.differences, null, 2));
  });
} else {
  console.log('✅ Schema is in sync');
}

Container Pruning

Remove containers that aren't in your schema:

// List orphaned containers
const orphaned = await db.management.listOrphanedContainers();
console.log(`Found ${orphaned.length} orphaned containers`);

// Preview what would be deleted
const preview = await db.management.pruneContainers({
  confirm: false,
  dryRun: true
});
console.log(`Would delete: ${preview.pruned.join(', ')}`);

// Actually delete
const result = await db.management.pruneContainers({
  confirm: true,
  exclude: ['keep-this-one'] // Optional: containers to keep
});

console.log(`Deleted: ${result.pruned.join(', ')}`);

Delete Containers

Delete containers by name:

const result = await db.management.deleteContainers(
  ['temp-container', 'old-data'],
  { confirm: true }
);

console.log(`Deleted: ${result.deleted.join(', ')}`);
console.log(`Failed: ${result.failed.length}`);

Best Practices

  1. Run health checks regularly to catch issues early
  2. Use schema diff to detect drift between code and database
  3. Backup before pruning containers in production
  4. Monitor orphaned containers - they still consume resources
  5. Review recommendations from health checks

Performance Tips

  • Database info queries are cached where possible
  • Health checks run queries to gather statistics
  • Schema diff is lightweight (metadata only)

Troubleshooting

"Must set confirm: true"

Cause: Safety check for destructive operations
Solution: Add confirm: true to the options


Next Steps

Deleting Documents

Learn how to delete documents and implement soft delete patterns

View on separate page →

Deleting documents requires careful consideration. Use hard deletes for performance, soft deletes for recovery.

Navigation:


Hard Delete

The basic delete operation removes a document permanently:

await db.users.delete({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  }
});

Important: Deletion is permanent and cannot be undone. Always ensure you have the partition key in the where clause—this is enforced at compile time.

Cost: ~5 RU

Soft Delete

Instead of permanently deleting documents, mark them as deleted:

// Instead of deleting, mark as inactive
await db.users.update({
  where: { id: 'user_123', email: 'john@example.com' },
  data: {
    isActive: false,
    deletedAt: new Date()
  }
});

// Query only active users
const activeUsers = await db.users.findMany({
  partitionKey: 'tenant_123',
  where: {
    isActive: true
  }
});

Why Soft Delete?

  • Data Recovery: Can restore deleted data
  • Audit Trail: Keep history of what was deleted and when
  • Referential Integrity: Other documents can still reference deleted items
  • Analytics: Analyze deleted data patterns

Implementing Soft Delete

Complete implementation:

// Define schema with soft delete fields
const users = container('users', {
  id: field.string(),
  email: field.string(),
  name: field.string(),
  isDeleted: field.boolean().default(false),
  deletedAt: field.date().optional(),
  deletedBy: field.string().optional()
}).partitionKey('email');

// Helper functions
async function softDeleteUser(id: string, email: string, deletedBy: string) {
  return await db.users.update({
    where: { id, email },
    data: {
      isDeleted: true,
      deletedAt: new Date(),
      deletedBy
    }
  });
}

async function restoreUser(id: string, email: string) {
  return await db.users.update({
    where: { id, email },
    data: {
      isDeleted: false,
      deletedAt: null,
      deletedBy: null
    }
  });
}

// Query excluding soft-deleted items
async function findAllUsers(partitionKey: string) {
  return await db.users.findMany({
    partitionKey,
    where: {
      isDeleted: false
    }
  });
}

Cleanup Old Soft Deletes

Automatically clean up old soft-deleted items:

async function cleanupOldSoftDeletes() {
  const thirtyDaysAgo = new Date();
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

  const deletedUsers = await db.users.findMany({
    enableCrossPartitionQuery: true,
    where: {
      isDeleted: true,
      deletedAt: { lt: thirtyDaysAgo }
    }
  });

  // Permanently delete old soft-deleted items
  for (const user of deletedUsers) {
    await db.users.delete({
      where: { id: user.id, email: user.email }
    });
  }
}

Cascade Delete Pattern

When deleting a parent document, you may need to delete related documents:

async function deleteUserWithRelatedData(userId: string, email: string) {
  // Find all related posts
  const posts = await db.posts.findMany({
    partitionKey: userId
  });

  // Delete all posts
  for (const post of posts) {
    await db.posts.delete({
      where: { id: post.id, userId }
    });
  }

  // Finally, delete the user
  await db.users.delete({
    where: { id: userId, email }
  });
}

Note: CosmosDB doesn't support foreign key constraints or cascade deletes at the database level. You must handle this logic in your application code.

Bulk Operations

For deleting multiple documents at once, use deleteMany:

// Delete old posts
const result = await db.posts.deleteMany({
  where: { createdAt: { lt: oneYearAgo } },
  confirm: true, // Safety: must explicitly confirm
  partitionKey: 'user123',
  onProgress: (stats) => {
    console.log(`Deleted ${stats.updated}/${stats.total}`);
  }
});

console.log(`Deleted ${result.deleted} documents`);
console.log(`Failed: ${result.failed}`);
console.log(`RU consumed: ${result.performance.ruConsumed}`);

Key Options:

  • where: Query to match documents
  • confirm: Must be true to execute (safety requirement)
  • partitionKey or enableCrossPartitionQuery: Required (one or the other)
  • batchSize: Documents per batch (default: 50)
  • maxConcurrency: Parallel batches (default: 5)
  • continueOnError: Keep going if some fail (default: false)
  • onProgress: Progress callback with stats

Result includes:

  • deleted: Number of successfully deleted documents
  • failed: Number of failed deletions
  • errors: Array of error details
  • performance: RU consumption, duration, and throughput metrics

Best Practices:

  1. Always use confirm: true - This prevents accidental deletions
  2. Start with small batches when testing (e.g., batchSize: 10)
  3. Use continueOnError: true for large operations where some failures are acceptable
  4. Monitor progress using onProgress callbacks
  5. Use partition keys when possible (much faster than cross-partition queries)

Note: For deleting documents by specific IDs in the same partition, you can still use individual deletes:

async function deleteManyPosts(postIds: string[], userId: string) {
  const deletePromises = postIds.map(id => 
    db.posts.delete({
      where: { id, userId }
    })
  );

  await Promise.all(deletePromises);
}

However, deleteMany is more efficient for query-based deletions with built-in progress tracking and error handling.

Bulk operations are built into CosmosQL and work seamlessly with your existing containers.

Delete by Query

For query-based deletions, use deleteMany instead of manually querying and deleting:

// ✅ Recommended: Use deleteMany
const result = await db.posts.deleteMany({
  where: {
    createdAt: { lt: cutoffDate },
    isPublished: false
  },
  confirm: true,
  enableCrossPartitionQuery: true,
  onProgress: (stats) => {
    console.log(`Progress: ${stats.percentage}%`);
  }
});

This is more efficient than querying and deleting individually, as it includes built-in progress tracking, error handling, and retry logic.

Error Handling

try {
  await db.users.delete({
    where: { id: 'user_123', email: 'john@example.com' }
  });
} catch (error) {
  if (error.code === 404) {
    // Document not found
    console.error('User not found');
  } else if (error.code === 429) {
    // Rate limit exceeded
    console.error('Rate limit exceeded, please retry');
  } else {
    console.error('Failed to delete user:', error);
  }
}

Performance Considerations

1. Always Include Partition Key

// ✅ Good: Includes partition key (fast, ~5 RU)
await db.users.delete({
  where: { id: 'user_123', email: 'john@example.com' }
});

2. Use Soft Delete for Frequent Deletions

Hard deletes are expensive if done repeatedly. Soft deletes allow you to batch hard deletes later:

// Mark as deleted immediately (fast)
await db.users.update({
  where: { id: 'user_123', email: 'john@example.com' },
  data: { isDeleted: true, deletedAt: new Date() }
});

// Batch permanent deletion later (background job)
setInterval(async () => {
  await cleanupOldSoftDeletes();
}, 60 * 60 * 1000); // Run every hour

Common Patterns

Pattern: Recycle Bin

async function moveToRecycleBin(postId: string, userId: string) {
  // Copy to recycle bin
  const post = await db.posts.findUnique({
    where: { id: postId, userId }
  });

  await db.recycleBin.create({
    data: {
      id: `bin_${postId}`,
      deletedItemId: postId,
      deletedItemType: 'post',
      deletedItemData: post,
      deletedAt: new Date()
    }
  });

  // Remove from original location
  await db.posts.delete({
    where: { id: postId, userId }
  });
}

async function restoreFromRecycleBin(itemId: string) {
  const binItem = await db.recycleBin.findUnique({
    where: { id: itemId }
  });

  if (binItem.deletedItemType === 'post') {
    await db.posts.create({
      data: binItem.deletedItemData
    });
  }

  await db.recycleBin.delete({ where: { id: itemId } });
}

Next Steps

Error Handling

A comprehensive guide to handling CosmosError and common issues

View on separate page →

CosmosQL provides comprehensive error handling through the CosmosError class:

import { CosmosError, isCosmosError } from 'cosmosql';

try {
  await db.users.create({ data: user });
} catch (error) {
  if (isCosmosError(error)) {
    // Type-safe error handling
    switch (error.statusCode) {
      case 400: // Bad Request
        console.error('Invalid request:', error.message);
        break;
      case 401: // Unauthorized
        console.error('Authentication failed - check your connection string');
        break;
      case 403: // Forbidden
        console.error('Access denied - check permissions');
        break;
      case 404: // Not Found
        console.error('Container or database not found');
        break;
      case 409: // Conflict (duplicate ID)
        console.error('Document already exists with this ID');
        break;
      case 412: // Precondition Failed (ETag mismatch)
        console.error('Document was modified, please retry');
        break;
      case 429: // Too Many Requests (rate limit)
        console.error('Rate limited, retry after:', error.retryAfter, 'seconds');
        // CosmosError includes retryAfter property
        break;
      case 500: // Internal Server Error
        console.error('CosmosDB service error:', error.message);
        break;
      default:
        console.error('CosmosDB error:', error.code, error.message);
    }
  } else {
    // Not a CosmosDB error, rethrow
    throw error;
  }
}

CosmosError Class

class CosmosError extends Error {
  statusCode: number;      // HTTP status code (400, 404, 429, etc.)
  code: string;            // CosmosDB error code
  message: string;         // Error message
  retryAfter?: number;     // Seconds to wait before retry (for 429 errors)
}

Common Error Codes

CodeStatusDescriptionCommon Causes
BadRequest400Invalid requestMalformed query syntax, invalid parameters
Unauthorized401Authentication failedInvalid connection string or key
Forbidden403Access deniedInsufficient permissions
NotFound404Resource not foundContainer/database doesn't exist, document not found
Conflict409Duplicate resourceDocument with same ID already exists
PreconditionFailed412ETag mismatchDocument was modified (optimistic concurrency)
TooManyRequests429Rate limitedRU/s limit exceeded, automatic retry available
InternalServerError500Service errorCosmosDB service issue
CROSS_PARTITION_QUERY_ERROR-Cross-partition query errorEmpty container cross-partition query

Common Error Scenarios

  • 404: Document not found (returns null for findUnique/findMany, throws for update/delete)
  • 429: Rate limited (automatic retry with backoff if configured via retryOptions)
  • 401/403: Authentication/authorization failures (check connection string and keys)
  • 400: Bad request (validation errors, invalid query syntax)
  • 409: Conflict (duplicate ID on create - indicates business logic issue)
  • 412: Precondition failed (ETag mismatch on updates - document was modified)
  • CROSS_PARTITION_QUERY_ERROR: Cross-partition queries on empty containers (CosmosDB limitation)

Auto-Retry on 429

CosmosQL automatically retries rate-limited requests with exponential backoff:

const db = await createClient({
  connectionString: '...',
  database: 'myapp',
  mode: 'verify', // Production: fail-fast on misconfiguration
  retryOptions: {
    maxRetries: 3,        // Maximum retry attempts
    initialDelay: 100,    // Initial delay in ms
    maxDelay: 5000        // Maximum delay in ms
  }
}).withContainers({ users });

// Automatically retries with exponential backoff on 429 errors
// Uses retryAfter from CosmosDB response when available

Error Handling Best Practices

  1. Always check error types - Use isCosmosError() for type-safe error handling
  2. Handle rate limits gracefully - Use built-in retry options or implement custom retry logic
  3. Log error details - Include statusCode, code, and message in error logs for debugging
  4. Don't ignore conflicts - 409 errors indicate business logic issues (duplicate IDs)
  5. Handle ETag mismatches - 412 errors mean document was modified; refetch and retry
  6. Check for cross-partition query errors - Ensure containers have data before cross-partition queries

Example: Registration with Error Handling

async function register(email: string, password: string) {
  const passwordHash = await bcrypt.hash(password, 10);
  
  try {
    const user = await db.users.create({
      data: {
        id: email,
        email: email,
        passwordHash,
        profile: { name: email.split('@')[0] },
        createdAt: new Date()
      }
    });
    
    return { success: true, user };
  } catch (error) {
    if (isCosmosError(error) && error.statusCode === 409) {
      // Conflict = duplicate email
      return { success: false, error: 'Email already exists' };
    }
    throw error;
  }
}

Performance

Optimize your CosmosQL queries for speed and cost efficiency

View on separate page →

Performance Best Practices

1. Always Use Partition Keys When Possible

The Cost Difference

// ❌ BAD: Cross-partition query (expensive)
const user = await db.users.findMany({
  enableCrossPartitionQuery: true,
  where: { age: { gt: 18 } }
});
// Cost: ~50-100 RU

// ✅ GOOD: Partition-scoped query
const user = await db.users.findMany({
  partitionKey: 'john@example.com',
  where: { age: { gt: 18 } }
});
// Cost: ~5 RU (10-20x cheaper)

2. Use Point Reads for Single Documents

Performance Tip

// ❌ OK: Query for single document
const user = await db.users.findMany({
  partitionKey: 'john@example.com',
  where: { id: 'user_123' },
  take: 1
});
// Cost: ~3-5 RU

// ✅ BEST: Point read (direct lookup)
const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' }
});
// Cost: ~1 RU (5x cheaper)

3. Select Only Needed Fields

// ❌ BAD: Fetch entire document
const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' }
});
// Returns all fields

// ✅ GOOD: Fetch only what you need
const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' },
  select: { name: true, email: true }
});
// Smaller payload = faster transfer = lower latency

4. Batch Operations in Same Partition

Batch Efficiency

// ❌ BAD: Individual creates (multiple requests)
for (const post of posts) {
  await db.posts.create({ data: post });
}
// 10 posts = 10 HTTP requests

// ✅ GOOD: Batch create (single request)
await db.posts.createMany({
  data: posts,
  partitionKey: 'user_123'
});
// 10 posts = 1 HTTP request (10x faster)

5. Use Appropriate Indexes

CosmosDB automatically indexes all properties by default, but you can optimize:

const users = container('users', {
  id: field.string(),
  email: field.string(),
  name: field.string(),
  metadata: field.object({ /* large nested object */ })
}).partitionKey('email')
  .indexingPolicy({
    automatic: true,
    includedPaths: [
      '/id/?',
      '/email/?',
      '/name/?'
    ],
    excludedPaths: [
      '/metadata/*' // Don't index large nested objects
    ]
  });

Reading Documents

Learn how to query and read documents from CosmosDB efficiently

View on separate page →

Reading data is the most common operation. CosmosQL provides three methods optimized for different scenarios.

Navigation:


Point Read (Fastest)

Use when: You know the exact ID and partition key.

Why it's fastest: Direct lookup, no scanning required.

Cost: ~1 RU (cheapest possible)

Example scenario: Loading a user's profile.

const user = await db.users.findUnique({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  }
});
// Returns: User | null

Type safety: TypeScript enforces both ID and partition key.

// ❌ This won't compile
const user = await db.users.findUnique({
  where: { id: 'user_123' }
  // Error: Property 'email' is missing
});

Query Within Partition

Use when: Filtering, sorting, or paginating within one partition.

Cost: ~3-5 RU (depends on result size)

Example scenario: Getting a user's posts.

const posts = await db.posts.findMany({
  partitionKey: 'user_123', // Required
  where: {
    isPublished: true,
    createdAt: { gte: new Date('2024-01-01') }
  },
  orderBy: { createdAt: 'desc' },
  take: 10
});
// Returns: Post[]

findMany with Aggregations

findMany can also return aggregations alongside your data - perfect for paginated lists with counts or statistics.

Example: Pagination with total count

const result = await db.posts.findMany({
  partitionKey: 'user_123',
  where: { isPublished: true },
  orderBy: { createdAt: 'desc' },
  take: 10,
  skip: 0,
  aggregate: {
    _count: true  // Count ALL matching posts
  }
});

console.log(`Showing ${result.data.length} of ${result._count} posts`);
console.log(`Page 1 of ${Math.ceil(result._count / 10)}`);
// Returns: { data: Post[], _count: number }

Example: Data with statistics

const result = await db.orders.findMany({
  partitionKey: 'customer-123',
  where: { status: 'completed' },
  take: 5,  // Last 5 orders
  orderBy: { createdAt: 'desc' },
  aggregate: {
    _count: true,
    _sum: { amount: true },
    _avg: { amount: true },
    _min: { createdAt: true },
    _max: { createdAt: true }
  }
});

// Access both data and stats
console.log(`Recent orders:`, result.data);
console.log(`Total completed orders: ${result._count}`);
console.log(`Total revenue: $${result._sum.amount}`);
console.log(`Average order value: $${result._avg.amount}`);
console.log(`Customer since: ${result._min.createdAt}`);
// Returns: { 
//   data: Order[],
//   _count: number,
//   _sum: { amount: number | null },
//   _avg: { amount: number | null },
//   _min: { createdAt: Date | null },
//   _max: { createdAt: Date | null }
// }

How it works:

  • Data query and aggregation execute in parallel
  • Both use the same where filter
  • Pagination (take/skip) only applies to data, not aggregations
  • Full type safety: TypeScript infers the complete return type

See full aggregations documentation below for more examples.

Cross-Partition Query

Use when: You need data across multiple partitions.

Cost: ~50-100+ RU (10-100x more expensive)

Requires: Explicit opt-in via enableCrossPartitionQuery: true

Example scenario: Global search across all users.

const recentUsers = await db.users.findMany({
  enableCrossPartitionQuery: true, // Must be explicit
  where: {
    createdAt: { gte: new Date('2024-01-01') }
  },
  orderBy: { createdAt: 'desc' },
  take: 20
});

When to use: Admin dashboards, analytics, reports—not user-facing queries.

Selecting Specific Fields

Use when: You don't need the full document.

Why: Reduces data transfer, faster queries, lower latency.

const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' },
  select: {
    name: true,
    email: true
  }
});
// Returns: { name: string; email: string } | null

Nested fields: Select specific fields from nested objects.

const post = await db.posts.findUnique({
  where: { id: 'post_1', userId: 'user_123' },
  select: {
    title: true,
    metadata: {
      source: true
    }
  }
});
// Returns: { title: string; metadata?: { source: string } } | null

Filtering with Where Clauses

Comprehensive filtering options:

await db.users.findMany({
  partitionKey: 'john@example.com',
  where: {
    // Exact match
    isActive: true,
    
    // Number comparisons
    age: { gt: 21 },        // Greater than
    score: { gte: 85 },     // Greater than or equal
    rating: { lt: 5 },      // Less than
    count: { lte: 100 },    // Less than or equal
    age: { gte: 18, lte: 65 }, // Range
    
    // String operations
    name: { startsWith: 'John' },    // STARTSWITH(c.name, 'John')
    email: { contains: 'gmail' },    // CONTAINS(c.email, 'gmail')
    bio: { endsWith: 'developer' },  // ENDSWITH(c.bio, 'developer')
    
    // Array operations
    tags: { contains: 'premium' },                    // CONTAINS array element
    // Note: containsAny and containsAll are planned features
    // For now, use contains for single checks or raw SQL for complex operations
    
    // Date comparisons
    createdAt: { gte: new Date('2024-01-01') },
    lastLoginAt: { lt: new Date() }
  }
});

Pagination

Implement pagination with take and skip:

const ITEMS_PER_PAGE = 20;

// Page 1
const page1 = await db.posts.findMany({
  partitionKey: 'user_123',
  take: ITEMS_PER_PAGE,
  skip: 0
});

// Page 2
const page2 = await db.posts.findMany({
  partitionKey: 'user_123',
  take: ITEMS_PER_PAGE,
  skip: ITEMS_PER_PAGE
});

Note: Large offsets can be expensive. For better performance, use cursor-based pagination with date filters.

Ordering Results

Sort results using orderBy:

// Single field ordering
await db.posts.findMany({
  partitionKey: 'user_123',
  orderBy: {
    createdAt: 'desc' // or 'asc'
  }
});

// Multiple field ordering
await db.posts.findMany({
  partitionKey: 'user_123',
  orderBy: [
    { createdAt: 'desc' },
    { title: 'asc' }
  ]
});

Raw SQL Queries

Use when: The query builder doesn't support your use case.

Example: Complex joins, CosmosDB-specific functions.

const result = await db.users.query<{ count: number }>({
  sql: `
    SELECT COUNT(1) as count 
    FROM c 
    WHERE c.isActive = @active 
    AND c.age > @minAge
  `,
  parameters: [
    { name: '@active', value: true },
    { name: '@minAge', value: 18 }
  ],
  partitionKey: 'john@example.com' // Optional: for partition-scoped query
});

Aggregations with findMany

Use when: You need both data and statistics in a single query.

Benefit: Combines data retrieval with aggregations efficiently.

findMany now supports aggregations, allowing you to fetch documents and calculate statistics (count, sum, avg, min, max) in a single query with full type safety.

Basic Example

const result = await db.orders.findMany({
  partitionKey: 'customer-123',
  where: { status: 'completed' },
  aggregate: {
    _count: true,
    _sum: { amount: true },
    _avg: { amount: true }
  }
});

console.log(`Found ${result._count} orders`);
console.log(`Total: $${result._sum.amount}`);
console.log(`Average: $${result._avg.amount}`);
console.log(`Orders:`, result.data);

Return Type Changes

Without aggregations:

const orders = await db.orders.findMany({ partitionKey: 'customer-123' });
// Type: Order[]

With aggregations:

const result = await db.orders.findMany({
  partitionKey: 'customer-123',
  aggregate: { _count: true }
});
// Type: { data: Order[], _count: number }

Pagination with Total Count

Perfect for building paginated UIs:

const result = await db.users.findMany({
  partitionKey: 'tenant-1',
  take: 20,
  skip: 0,
  aggregate: { _count: true }  // Counts ALL matching records
});

console.log(`Page 1 of ${Math.ceil(result._count / 20)}`);
console.log(`Showing ${result.data.length} of ${result._count} users`);

Available Aggregations

All aggregation operations are supported:

const result = await db.products.findMany({
  partitionKey: 'category-electronics',
  aggregate: {
    _count: true,                         // Count all records
    _sum: { price: true, stock: true },   // Sum numeric fields
    _avg: { price: true },                // Average values
    _min: { price: true, createdAt: true }, // Minimum values
    _max: { price: true, createdAt: true }  // Maximum values
  }
});

See the Aggregations Guide for complete documentation.

Performance Best Practices

1. Always Prefer Point Reads

// ✅ Good: Point read (~1 RU)
const user = await db.users.findUnique({
  where: { id: 'user_123', email: 'john@example.com' }
});

// ⚠️ Slower: Query even with unique result (~5 RU)
const user = await db.users.findMany({
  partitionKey: 'john@example.com',
  where: { id: 'user_123' },
  take: 1
});

2. Use Partition Keys Efficiently

// ✅ Good: All queries scoped to partition
await db.posts.findMany({
  partitionKey: 'user_123',
  where: { isPublished: true }
});

// ❌ Bad: Cross-partition query when not needed
await db.posts.findMany({
  where: { userId: 'user_123', isPublished: true },
  enableCrossPartitionQuery: true
});

3. Limit Result Sets

// ✅ Good: Limit to what you need
await db.posts.findMany({
  partitionKey: 'user_123',
  take: 10 // Only fetch 10 results
});

4. Use Select to Reduce Data Transfer

// ✅ Good: Only fetch needed fields
await db.users.findMany({
  partitionKey: 'tenant_123',
  select: { id: true, name: true }
});

Common Query Patterns

Pattern: Getting a Count

const count = await db.posts.query<{ count: number }>({
  sql: `
    SELECT VALUE COUNT(1)
    FROM c
    WHERE c.isPublished = true
    AND c.publishedAt > @date
  `,
  parameters: [{ name: '@date', value: new Date('2024-01-01') }],
  enableCrossPartitionQuery: true
});

Pattern: Search Within Partition

const matchingPosts = await db.posts.findMany({
  partitionKey: 'user_123',
  where: {
    title: { contains: searchTerm },
    isPublished: true
  }
});

Pattern: Conditional Queries

const where: any = { isPublished: true };

if (minDate) {
  where.publishedAt = { gte: minDate };
}

if (category) {
  where.category = category;
}

const posts = await db.posts.findMany({
  partitionKey: 'user_123',
  where
});

Next Steps

Migrations

Structured schema and data evolution for your CosmosDB database

View on separate page →

Migrations provide a structured way to evolve your database schema and data over time. Define migrations, track their status, apply them safely, and rollback when needed.

Navigation:


Defining Migrations

Create migrations using defineMigration:

import { defineMigration } from 'cosmosql';

export const addPreferences = defineMigration({
  version: 1,
  name: 'add-user-preferences',
  description: 'Add preferences object to all users',
  
  async up({ db, logger, progress }) {
    logger.info('Adding preferences to users...');
    
    const result = await db.users.updateMany({
      where: {},
      data: (doc) => ({
        preferences: {
          theme: doc.oldTheme || 'light',
          notifications: true
        }
      }),
      enableCrossPartitionQuery: true,
      onProgress: progress.track('users')
    });
    
    logger.info(`Updated ${result.updated} users`);
  },
  
  async down({ db, logger }) {
    logger.info('Removing preferences from users...');
    
    await db.users.updateMany({
      where: {},
      data: { preferences: undefined },
      enableCrossPartitionQuery: true
    });
  }
});

Migration Context:

  • db: Your database client with all containers
  • logger: Logger with info, warn, error, debug methods
  • progress: Progress tracker for bulk operations
  • dryRun: Boolean indicating if this is a dry run

Rules:

  • Versions must be sequential integers (1, 2, 3...)
  • Names must be lowercase alphanumeric with hyphens
  • up() is required, down() is optional (but recommended)

Registering Migrations

Pass migrations when creating the client:

const db = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'myapp',
  migrations: [addPreferences, migration2, migration3]
}).withContainers({ users, posts });

Migration Status

Check which migrations are applied:

const status = await db.migrations.status();

console.log(`Current version: ${status.current?.version}`);
console.log(`Pending: ${status.pending.length}`);
console.log(`Can rollback: ${status.canRollback}`);

// List all applied migrations
status.applied.forEach(m => {
  console.log(`v${m.version}: ${m.name} (${m.ruConsumed} RU)`);
});

Planning Migrations

Preview what will happen before applying:

const plan = await db.migrations.plan({ dryRun: true });

console.log(`Will apply ${plan.migrationsToApply.length} migrations`);
console.log(`Estimated cost: ${plan.totalEstimatedRU} RU`);
console.log(`Estimated duration: ${plan.totalEstimatedDuration}`);

// Warnings
plan.warnings.forEach(w => console.log(`⚠️  ${w}`));

Applying Migrations

Apply pending migrations:

const result = await db.migrations.apply({
  target: 'latest', // or specific version number
  confirm: true,    // Safety confirmation
  onProgress: (p) => {
    console.log(
      `[Migration ${p.migration.version}] ${p.status} - ` +
      `${p.percentage}% (${p.ruConsumed} RU)`
    );
  }
});

console.log(`Applied ${result.applied.length} migrations`);
console.log(`Total RU: ${result.performance.totalRuConsumed}`);

Dry Run:

Test migrations without applying them:

await db.migrations.apply({
  target: 'latest',
  dryRun: true,
  onProgress: (p) => console.log(`Would apply: ${p.migration.name}`)
});

Rolling Back

Rollback to a previous version:

await db.migrations.rollback({
  to: 3,        // Roll back to version 3
  confirm: true, // Safety confirmation
  onProgress: (p) => {
    console.log(`Rolling back: ${p.migration.name}`);
  }
});

Requirements:

  • All migrations being rolled back must have down() functions
  • Migrations are rolled back in reverse order

Best Practices

  1. Never modify applied migrations - the system detects changes via checksums
  2. Always provide down() functions for rollback capability
  3. Test migrations on dev/staging before production
  4. Use dryRun: true to preview changes
  5. Keep migrations small and focused - one logical change per migration
  6. Version sequentially - no gaps in version numbers

Performance Tips

  • Migrations are tracked in a special _migrations container
  • Progress tracking adds minimal overhead
  • Use bulk operations within migrations for efficiency

Troubleshooting

"Migrations must be sequential"

Cause: Gap in version numbers
Solution: Ensure versions are 1, 2, 3... with no gaps

"Cannot rollback: migration has no down() function"

Cause: Trying to rollback a migration without down()
Solution: Add down() function or remove the migration


Next Steps

Updating Documents

Learn how to update existing documents in CosmosDB

View on separate page →

CosmosQL provides flexible and type-safe ways to update documents in CosmosDB.

Navigation:


Single Update

The basic update operation modifies an existing document:

await db.users.update({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  },
  data: {
    age: 31,
    name: 'John Updated'
  }
});

Important: The partition key must be included in the where clause. This is enforced at compile time for type safety.

Partial Updates

You can update only the fields you need—other fields remain unchanged:

await db.users.update({
  where: { id: 'user_123', email: 'john@example.com' },
  data: {
    age: 31
    // Other fields remain unchanged
  }
});

This is efficient as CosmosDB only updates the specified fields, not the entire document.

Upsert Operations

The upsert operation inserts if the document doesn't exist, or updates if it does:

await db.users.upsert({
  where: {
    id: 'user_123',
    email: 'john@example.com'
  },
  create: {
    // Full user object for creation
    id: 'user_123',
    email: 'john@example.com',
    name: 'John Doe',
    age: 30,
    isActive: true,
    createdAt: new Date()
  },
  update: {
    // Partial update
    age: 31,
    name: 'John Updated'
  }
});

Why Upsert? This pattern is common in distributed systems where you want idempotent operations—operations that can be safely retried without unintended side effects.

Atomic Operations

Perform atomic increment/decrement operations:

await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: {
    viewCount: { increment: 1 },
    likeCount: { increment: 5 },
    dislikeCount: { decrement: 2 }
  }
});

This is an atomic operation performed server-side, making it safe for concurrent updates.

Update Patterns

Pattern: Touch Pattern (Update Timestamp)

await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: {
    title: 'New Title',
    updatedAt: new Date()
  }
});

Pattern: Status Transitions

// Update status with validation
const validTransitions = {
  draft: ['published', 'archived'],
  published: ['archived'],
  archived: [] // terminal state
};

function canTransition(from: string, to: string): boolean {
  return validTransitions[from]?.includes(to) ?? false;
}

async function updatePostStatus(postId: string, userId: string, newStatus: string) {
  const post = await db.posts.findUnique({
    where: { id: postId, userId }
  });

  if (!post || !canTransition(post.status, newStatus)) {
    throw new Error('Invalid status transition');
  }

  await db.posts.update({
    where: { id: postId, userId },
    data: {
      status: newStatus,
      updatedAt: new Date()
    }
  });
}

Pattern: Array Operations

While CosmosQL doesn't provide built-in array manipulation helpers, you can update entire arrays:

// Replace the entire tags array
await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: {
    tags: ['javascript', 'react', 'tutorial']
  }
});

// For append operations, fetch, modify, and update
const post = await db.posts.findUnique({
  where: { id: 'post_1', userId: 'user_123' }
});

await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: {
    tags: [...post.tags, 'new-tag']
  }
});

Bulk Operations

For updating multiple documents at once, use updateMany:

// Static update - update all inactive users
const result = await db.users.updateMany({
  where: { isActive: false },
  data: { status: 'archived' },
  partitionKey: 'user@email.com' // or enableCrossPartitionQuery: true
});

console.log(`Updated ${result.updated} documents`);
console.log(`Failed: ${result.failed}`);
console.log(`RU consumed: ${result.performance.ruConsumed}`);

Dynamic update with function:

// Update email domains for all users
const result = await db.users.updateMany({
  where: { email: { contains: '@old.com' } },
  data: (doc) => ({
    email: doc.email.replace('@old.com', '@new.com'),
    migratedAt: new Date()
  }),
  enableCrossPartitionQuery: true,
  batchSize: 50,
  maxConcurrency: 5,
  onProgress: (stats) => {
    console.log(`${stats.percentage}% - ${stats.ruConsumed} RU`);
  },
  onError: (error) => {
    console.error(`Failed: ${error.documentId}`, error.error);
  }
});

Key Options:

  • where: Query to match documents
  • data: Static object or function that returns updates
  • partitionKey or enableCrossPartitionQuery: Required (one or the other)
  • batchSize: Documents per batch (default: 50)
  • maxConcurrency: Parallel batches (default: 5)
  • continueOnError: Keep going if some fail (default: false)
  • maxRetries: Retry attempts for retriable errors (default: 3)
  • onProgress: Progress callback with stats
  • onError: Error callback for individual failures

Result includes:

  • updated: Number of successfully updated documents
  • failed: Number of failed updates
  • skipped: Number of skipped documents
  • errors: Array of error details
  • performance: RU consumption, duration, and throughput metrics

Best Practices:

  1. Start with small batches when testing (e.g., batchSize: 10)
  2. Use continueOnError: true for large operations where some failures are acceptable
  3. Monitor RU consumption using onProgress callbacks
  4. Use partition keys when possible (much faster than cross-partition queries)
  5. Test with dry runs if implementing custom logic

Bulk operations are built into CosmosQL and work seamlessly with your existing containers.

Error Handling

try {
  await db.users.update({
    where: { id: 'user_123', email: 'john@example.com' },
    data: { age: 31 }
  });
} catch (error) {
  if (error.code === 404) {
    // Document not found
    console.error('User not found');
  } else if (error.code === 429) {
    // Rate limit exceeded
    console.error('Rate limit exceeded, please retry');
  } else if (error.code === 412) {
    // Precondition failed (e.g., ETag mismatch)
    console.error('Document was modified, please retry');
  } else {
    console.error('Failed to update user:', error);
  }
}

Performance Considerations

1. Use Partial Updates

// ✅ Good: Only update what changed
await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: { title: 'New Title' }
});

2. Batch Related Updates

// ✅ Good: Single atomic operation
await db.posts.update({
  where: { id: 'post_1', userId: 'user_123' },
  data: {
    viewCount: { increment: 1 },
    lastViewedAt: new Date()
  }
});

Next Steps

Database Management

Comprehensive tools for managing and monitoring your CosmosDB resources

View on separate page →

Manage and monitor your CosmosDB database with built-in tools for health checks, schema validation, and container management.

Navigation:


Database Information

Get detailed information about your database:

const info = await db.management.getDatabaseInfo();

console.log(`Database: ${info.id}`);
console.log(`Containers: ${info.containersCount}`);
console.log(`Total documents: ${info.storage.totalDocuments}`);
console.log(`Storage: ${info.storage.totalSizeGB} GB`);

// Container details
info.containers.forEach(c => {
  console.log(`\n${c.id}:`);
  console.log(`  Documents: ${c.statistics.documentCount}`);
  console.log(`  Partition Key: ${c.partitionKey.paths[0]}`);
  console.log(`  Registered: ${c.schema?.registered}`);
});

Health Checks

Run health checks on your database:

const health = await db.management.healthCheck();

console.log(`Overall Health: ${health.overallHealth}`); // 'healthy', 'warning', or 'critical'

health.containers.forEach(c => {
  if (!c.healthy) {
    console.log(`⚠️  ${c.container}:`);
    c.issues.forEach(issue => {
      console.log(`  [${issue.severity}] ${issue.message}`);
      if (issue.recommendation) {
        console.log(`  💡 ${issue.recommendation}`);
      }
    });
  }
});

// Recommendations
health.recommendations.forEach(r => console.log(`💡 ${r}`));

Issue Types:

  • missing_index: Container has very few indexed paths
  • large_documents: Average document size is high
  • orphaned: Container not registered in schema

Schema Diff

Compare your schema with the actual database:

const diff = await db.management.diffSchema();

if (diff.requiresAction) {
  console.log('⚠️  Schema drift detected!');
  
  // Containers in database but not schema
  if (diff.containers.orphaned.length > 0) {
    console.log(`Orphaned: ${diff.containers.orphaned.join(', ')}`);
  }
  
  // Containers in schema but not database
  if (diff.containers.missing.length > 0) {
    console.log(`Missing: ${diff.containers.missing.join(', ')}`);
  }
  
  // Configuration differences
  diff.containers.modified.forEach(mod => {
    console.log(`${mod.container} has differences:`);
    console.log(JSON.stringify(mod.differences, null, 2));
  });
} else {
  console.log('✅ Schema is in sync');
}

Container Pruning

Remove containers that aren't in your schema:

// List orphaned containers
const orphaned = await db.management.listOrphanedContainers();
console.log(`Found ${orphaned.length} orphaned containers`);

// Preview what would be deleted
const preview = await db.management.pruneContainers({
  confirm: false,
  dryRun: true
});
console.log(`Would delete: ${preview.pruned.join(', ')}`);

// Actually delete
const result = await db.management.pruneContainers({
  confirm: true,
  exclude: ['keep-this-one'] // Optional: containers to keep
});

console.log(`Deleted: ${result.pruned.join(', ')}`);

Delete Containers

Delete containers by name:

const result = await db.management.deleteContainers(
  ['temp-container', 'old-data'],
  { confirm: true }
);

console.log(`Deleted: ${result.deleted.join(', ')}`);
console.log(`Failed: ${result.failed.length}`);

Best Practices

  1. Run health checks regularly to catch issues early
  2. Use schema diff to detect drift between code and database
  3. Backup before pruning containers in production
  4. Monitor orphaned containers - they still consume resources
  5. Review recommendations from health checks

Performance Tips

  • Database info queries are cached where possible
  • Health checks run queries to gather statistics
  • Schema diff is lightweight (metadata only)

Troubleshooting

"Must set confirm: true"

Cause: Safety check for destructive operations
Solution: Add confirm: true to the options


Next Steps

Migrations

Structured schema and data evolution for your CosmosDB database

View on separate page →

Migrations provide a structured way to evolve your database schema and data over time. Define migrations, track their status, apply them safely, and rollback when needed.

Navigation:


Defining Migrations

Create migrations using defineMigration:

import { defineMigration } from 'cosmosql';

export const addPreferences = defineMigration({
  version: 1,
  name: 'add-user-preferences',
  description: 'Add preferences object to all users',
  
  async up({ db, logger, progress }) {
    logger.info('Adding preferences to users...');
    
    const result = await db.users.updateMany({
      where: {},
      data: (doc) => ({
        preferences: {
          theme: doc.oldTheme || 'light',
          notifications: true
        }
      }),
      enableCrossPartitionQuery: true,
      onProgress: progress.track('users')
    });
    
    logger.info(`Updated ${result.updated} users`);
  },
  
  async down({ db, logger }) {
    logger.info('Removing preferences from users...');
    
    await db.users.updateMany({
      where: {},
      data: { preferences: undefined },
      enableCrossPartitionQuery: true
    });
  }
});

Migration Context:

  • db: Your database client with all containers
  • logger: Logger with info, warn, error, debug methods
  • progress: Progress tracker for bulk operations
  • dryRun: Boolean indicating if this is a dry run

Rules:

  • Versions must be sequential integers (1, 2, 3...)
  • Names must be lowercase alphanumeric with hyphens
  • up() is required, down() is optional (but recommended)

Registering Migrations

Pass migrations when creating the client:

const db = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'myapp',
  migrations: [addPreferences, migration2, migration3]
}).withContainers({ users, posts });

Migration Status

Check which migrations are applied:

const status = await db.migrations.status();

console.log(`Current version: ${status.current?.version}`);
console.log(`Pending: ${status.pending.length}`);
console.log(`Can rollback: ${status.canRollback}`);

// List all applied migrations
status.applied.forEach(m => {
  console.log(`v${m.version}: ${m.name} (${m.ruConsumed} RU)`);
});

Planning Migrations

Preview what will happen before applying:

const plan = await db.migrations.plan({ dryRun: true });

console.log(`Will apply ${plan.migrationsToApply.length} migrations`);
console.log(`Estimated cost: ${plan.totalEstimatedRU} RU`);
console.log(`Estimated duration: ${plan.totalEstimatedDuration}`);

// Warnings
plan.warnings.forEach(w => console.log(`⚠️  ${w}`));

Applying Migrations

Apply pending migrations:

const result = await db.migrations.apply({
  target: 'latest', // or specific version number
  confirm: true,    // Safety confirmation
  onProgress: (p) => {
    console.log(
      `[Migration ${p.migration.version}] ${p.status} - ` +
      `${p.percentage}% (${p.ruConsumed} RU)`
    );
  }
});

console.log(`Applied ${result.applied.length} migrations`);
console.log(`Total RU: ${result.performance.totalRuConsumed}`);

Dry Run:

Test migrations without applying them:

await db.migrations.apply({
  target: 'latest',
  dryRun: true,
  onProgress: (p) => console.log(`Would apply: ${p.migration.name}`)
});

Rolling Back

Rollback to a previous version:

await db.migrations.rollback({
  to: 3,        // Roll back to version 3
  confirm: true, // Safety confirmation
  onProgress: (p) => {
    console.log(`Rolling back: ${p.migration.name}`);
  }
});

Requirements:

  • All migrations being rolled back must have down() functions
  • Migrations are rolled back in reverse order

Best Practices

  1. Never modify applied migrations - the system detects changes via checksums
  2. Always provide down() functions for rollback capability
  3. Test migrations on dev/staging before production
  4. Use dryRun: true to preview changes
  5. Keep migrations small and focused - one logical change per migration
  6. Version sequentially - no gaps in version numbers

Performance Tips

  • Migrations are tracked in a special _migrations container
  • Progress tracking adds minimal overhead
  • Use bulk operations within migrations for efficiency

Troubleshooting

"Migrations must be sequential"

Cause: Gap in version numbers
Solution: Ensure versions are 1, 2, 3... with no gaps

"Cannot rollback: migration has no down() function"

Cause: Trying to rollback a migration without down()
Solution: Add down() function or remove the migration


Next Steps

Authentication

User login and registration patterns using CosmosQL

View on separate page →

The pattern: Use email as both ID and partition key for user documents.

Why this works:

  • Login queries are partition-scoped (fast + cheap)
  • Natural 1:1 mapping between user and partition
  • Scales to millions of users
  • No "hot partitions" (even distribution)

When to avoid:

  • Users can change email addresses → Use immutable userId instead
  • Multiple authentication providers → Requires composite keys

Schema Design

const users = container('users', {
  id: field.string(),        // Using email as ID
  email: field.string(),     // Also the partition key
  passwordHash: field.string(),
  profile: field.object({
    name: field.string(),
    avatar: field.string().optional()
  }),
  createdAt: field.date(),
  lastLoginAt: field.date().optional()
}).partitionKey('email'); // Key decision: email is partition key

Why email as both ID and partition key?

  • Point reads during login (fastest possible: 1 RU)
  • No duplicate emails (ID uniqueness)
  • Even distribution across partitions

Registration

async function register(email: string, password: string) {
  const passwordHash = await bcrypt.hash(password, 10);
  
  try {
    const user = await db.users.create({
      data: {
        id: email,           // Using email as ID
        email: email,        // And partition key
        passwordHash,
        profile: { name: email.split('@')[0] },
        createdAt: new Date()
      }
    });
    
    return { success: true, user };
  } catch (error) {
    if (isCosmosError(error) && error.statusCode === 409) {
      // Conflict = duplicate email
      return { success: false, error: 'Email already exists' };
    }
    throw error;
  }
}

What makes this efficient:

  • One partition write (5 RU)
  • Automatic duplicate detection (409 conflict)
  • Type-safe return values

Login

async function login(email: string, password: string) {
  // Point read: fastest possible query (1 RU)
  const user = await db.users.findUnique({
    where: {
      id: email,
      email: email // Partition key
    },
    select: {
      id: true,
      email: true,
      passwordHash: true,
      profile: true
    }
  });
  
  if (!user) {
    return { success: false, error: 'Invalid credentials' };
  }
  
  // Verify password
  const valid = await bcrypt.compare(password, user.passwordHash);
  if (!valid) {
    return { success: false, error: 'Invalid credentials' };
  }
  
  // Update last login (separate operation to keep read fast)
  await db.users.update({
    where: { id: email, email },
    data: { lastLoginAt: new Date() }
  });
  
  return { success: true, user };
}

Performance breakdown:

  • Read: 1 RU (point read)
  • Update: 5 RU (partition-scoped write)
  • Total: 6 RU per login

Alternative: Skip the update to save 5 RU if you don't need lastLoginAt.

Email Change (Advanced)

Problem: User wants to change email, but email is the partition key.

Solution: Create new document, migrate data, delete old.

async function changeEmail(oldEmail: string, newEmail: string) {
  // 1. Check if new email exists
  const existing = await db.users.findUnique({
    where: { id: newEmail, email: newEmail }
  });
  
  if (existing) {
    return { success: false, error: 'Email already in use' };
  }
  
  // 2. Get current user
  const user = await db.users.findUnique({
    where: { id: oldEmail, email: oldEmail }
  });
  
  if (!user) {
    return { success: false, error: 'User not found' };
  }
  
  // 3. Create new document with new email
  await db.users.create({
    data: {
      ...user,
      id: newEmail,
      email: newEmail
    }
  });
  
  // 4. Delete old document
  await db.users.delete({
    where: { id: oldEmail, email: oldEmail }
  });
  
  return { success: true };
}

Cost: ~15 RU (read + create + delete)
Caveat: Not atomic—handle failures appropriately

E-Commerce Catalog

Product catalogs and inventory management patterns

View on separate page →

The pattern: Partition by category for efficient product queries.

Why this works:

  • Fast category browsing
  • Good distribution across partitions
  • Cross-partition queries only for global search

Schema Design

const products = container('products', {
  id: field.string(),
  category: field.string(), // Partition key
  name: field.string(),
  price: field.number(),
  inventory: field.number(),
  tags: field.array(field.string()),
  metadata: field.object({
    brand: field.string(),
    weight: field.number().optional(),
    dimensions: field.object({
      length: field.number(),
      width: field.number(),
      height: field.number()
    }).optional()
  })
}).partitionKey('category');

Product Queries

// Query products by category with filtering
const electronics = await db.products.findMany({
  partitionKey: 'electronics',
  where: {
    price: { lte: 999 },
    inventory: { gt: 0 },
    tags: { contains: 'wireless' }
  },
  select: {
    id: true,
    name: true,
    price: true,
    metadata: {
      brand: true
    }
  },
  orderBy: { price: 'asc' },
  take: 20
});

For global search across all categories, use cross-partition queries (expensive but necessary):

// Global product search (requires cross-partition query)
const searchResults = await db.products.findMany({
  enableCrossPartitionQuery: true,
  where: {
    name: { contains: searchTerm },
    inventory: { gt: 0 }
  },
  orderBy: { price: 'asc' },
  take: 50
});

Inventory Management

// Update inventory after purchase
await db.products.update({
  where: {
    id: productId,
    category: productCategory
  },
  data: {
    inventory: { decrement: quantity }
  }
});

Best Practices

  1. Category selection - Choose categories with good distribution (avoid "Other" being too large)
  2. Cache popular categories - Frequently browsed categories benefit from caching
  3. Monitor hot partitions - If one category has 80% of products, consider sub-categorization

Multi-Tenant SaaS

Data isolation per tenant using partition keys

View on separate page →

The pattern: Use tenantId as partition key to ensure data isolation.

Why this works:

  • Complete isolation between tenants
  • All queries automatically scoped to a single tenant
  • Prevents cross-tenant data leakage
  • Scales as tenants grow

Schema Design

const organizations = container('organizations', {
  id: field.string(),
  name: field.string(),
  plan: field.string(),
  settings: field.object({
    theme: field.string().default('light'),
    features: field.array(field.string())
  })
}).partitionKey('id'); // Organization ID as partition key

const users = container('users', {
  id: field.string(),
  organizationId: field.string(),
  email: field.string(),
  role: field.string(),
  profile: field.object({
    name: field.string(),
    department: field.string().optional()
  })
}).partitionKey('organizationId'); // All users for an org in same partition

const documents = container('documents', {
  id: field.string(),
  tenantId: field.string(),
  name: field.string(),
  content: field.string(),
  createdBy: field.string(),
  createdAt: field.date()
}).partitionKey('tenantId');

Querying Tenant Data

// All queries automatically scoped to tenant
async function getDocuments(tenantId: string) {
  return await db.documents.findMany({
    partitionKey: tenantId,
    orderBy: { createdAt: 'desc' }
  });
}

async function getOrgUsers(organizationId: string) {
  return await db.users.findMany({
    partitionKey: organizationId,
    where: {
      role: 'admin'
    },
    select: {
      id: true,
      email: true,
      profile: { name: true }
    }
  });
}

Security Best Practices

  1. Always validate tenant context - Ensure the authenticated user's tenant matches the partition key
  2. Use middleware - Automatically inject tenant ID from request context
  3. Prevent cross-tenant access - Never allow user-provided tenant IDs without validation
// Example: Express middleware to enforce tenant isolation
function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
  const tenantId = req.user?.organizationId;
  if (!tenantId) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  req.tenantId = tenantId; // Attach to request
  next();
}

// Use in routes
app.get('/documents', tenantMiddleware, async (req, res) => {
  const documents = await db.documents.findMany({
    partitionKey: req.tenantId, // Always use validated tenant ID
    orderBy: { createdAt: 'desc' }
  });
  res.json(documents);
});

Shopping Carts

E-commerce shopping cart patterns with TTL for abandoned carts

View on separate page →

The pattern: Use userId as partition key with TTL for abandoned carts.

Schema Design

const carts = container('carts', {
  id: field.string(),
  userId: field.string(),
  items: field.array(field.object({
    productId: field.string(),
    quantity: field.number(),
    price: field.number()
  })),
  total: field.number(),
  updatedAt: field.date(),
  ttl: field.number() // Expire abandoned carts
}).partitionKey('userId');

Cart Operations

async function addToCart(userId: string, item: CartItem) {
  const cart = await db.carts.findUnique({
    where: { id: userId, userId }
  });
  
  if (!cart) {
    return await db.carts.create({
      data: {
        id: userId,
        userId,
        items: [item],
        total: item.price * item.quantity,
        updatedAt: new Date(),
        ttl: 7 * 24 * 60 * 60 // Expire after 7 days
      }
    });
  }
  
  // Update existing cart
  const newItems = [...cart.items, item];
  const newTotal = newItems.reduce((sum, i) => sum + i.price * i.quantity, 0);
  
  return await db.carts.update({
    where: { id: userId, userId },
    data: {
      items: newItems,
      total: newTotal,
      updatedAt: new Date()
    }
  });
}

Cart Retrieval

// Get user's cart (fast point read)
const cart = await db.carts.findUnique({
  where: {
    id: userId,
    userId
  }
});

if (!cart) {
  // Cart expired or doesn't exist
  return { items: [], total: 0 };
}

Checkout

async function checkout(userId: string) {
  const cart = await db.carts.findUnique({
    where: { id: userId, userId }
  });
  
  if (!cart || cart.items.length === 0) {
    throw new Error('Cart is empty');
  }
  
  // Create order (in different container)
  const order = await db.orders.create({
    data: {
      id: generateId(),
      userId: userId,
      items: cart.items,
      total: cart.total,
      status: 'pending',
      createdAt: new Date()
    }
  });
  
  // Clear cart (or let TTL expire it)
  await db.carts.delete({
    where: { id: userId, userId }
  });
  
  return order;
}

Best Practices

  1. TTL for abandoned carts - Automatically clean up old carts
  2. Update TTL on cart activity - Refresh expiration when user interacts
  3. Partition by userId - All cart operations scoped to user
  4. Consider cart expiration - Match business requirements (7-30 days typical)

Social Platform Feeds

User-generated content and social feed patterns

View on separate page →

The pattern: Partition by userId to keep all user content together.

Why this works:

  • Fast queries for a user's posts, comments, activity
  • All related data in one partition
  • Efficient for user profiles and feeds

Schema Design

const posts = container('posts', {
  id: field.string(),
  userId: field.string(), // Partition key
  content: field.string(),
  likes: field.number().default(0),
  createdAt: field.date(),
  tags: field.array(field.string()),
  author: field.object({
    name: field.string(),
    avatar: field.string().optional()
  })
}).partitionKey('userId');

User Content Queries

// Find user's recent posts with engagement
const userPosts = await db.posts.findMany({
  partitionKey: 'user_123',
  where: {
    createdAt: { gte: new Date('2024-01-01') },
    likes: { gt: 10 }
  },
  select: {
    id: true,
    content: true,
    likes: true,
    author: {
      name: true
    }
  },
  orderBy: { createdAt: 'desc' },
  take: 50
});

Global Feed (Cross-Partition)

For a global feed showing posts from all users:

// Global feed (requires cross-partition query)
const globalFeed = await db.posts.findMany({
  enableCrossPartitionQuery: true,
  where: {
    createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } // Last 7 days
  },
  orderBy: { likes: 'desc' },
  take: 100
});

Engagement Tracking

// Increment likes (atomic update)
await db.posts.update({
  where: {
    id: postId,
    userId: userId
  },
  data: {
    likes: { increment: 1 }
  }
});

Best Practices

  1. Prefer user-scoped queries - Most social platforms show user-specific content
  2. Cache global feeds - Cross-partition queries are expensive; cache frequently
  3. Consider denormalization - Store author info in posts to avoid joins

Time-Series Data

Metrics, logs, and time-series data patterns with TTL

View on separate page →

The pattern: Use deviceId as partition key with TTL for automatic cleanup.

Why this works:

  • Fast queries per device
  • Automatic expiration with TTL
  • Good distribution across devices

Schema Design

const metrics = container('metrics', {
  id: field.string(),
  deviceId: field.string(), // Partition key
  timestamp: field.date(),
  temperature: field.number(),
  humidity: field.number(),
  ttl: field.number() // Auto-delete old data
}).partitionKey('deviceId');

Time-Series Queries

// Ingest metric (auto-expires after 30 days)
await db.metrics.create({
  data: {
    id: generateId(),
    deviceId: 'device_123',
    timestamp: new Date(),
    temperature: 22.5,
    humidity: 45,
    ttl: 30 * 24 * 60 * 60 // 30 days in seconds
  }
});

// Query recent metrics for device
const recent = await db.metrics.findMany({
  partitionKey: 'device_123',
  where: {
    timestamp: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
  },
  orderBy: { timestamp: 'desc' }
});

Aggregations

// Get average temperature over time period
const stats = await db.metrics.aggregate({
  partitionKey: 'device_123',
  where: {
    timestamp: { 
      gte: new Date('2024-01-01'),
      lte: new Date('2024-01-31')
    }
  },
  _avg: { temperature: true, humidity: true },
  _min: { temperature: true },
  _max: { temperature: true },
  _count: true
});

Best Practices

  1. Use TTL for automatic cleanup - Avoids manual deletion jobs
  2. Partition by device/entity - Keeps related data together
  3. Index timestamp - Ensure efficient time-range queries
  4. Consider retention policies - Set TTL based on data retention requirements

Contributing

Development setup, code quality, and contribution guidelines for CosmosQL

View on separate page →

Thank you for your interest in contributing to CosmosQL! This guide will help you get started with development.

Development Setup

Prerequisites

  • Node.js 18+ or Bun 1.0+
  • TypeScript 5.0+
  • Git for version control

Getting Started

  1. Clone the repository:
git clone https://github.com/Stoffberg/cosmosql.git
cd cosmosql
  1. Install dependencies:
# Using Bun (recommended)
bun install

# Or using npm
npm install
  1. Build the project:
bun run build

# Or
npm run build
  1. Run tests:
# All tests
bun test

# Unit tests only
bun test tests/unit

# Integration tests only
bun test tests/integration

Note: Integration tests require a real Azure CosmosDB account. Set up environment variables:

COSMOS_ENDPOINT=https://your-account.documents.azure.com:443/
COSMOS_KEY=your-key
COSMOS_DATABASE=test-db

Code Quality

CosmosQL maintains high code quality standards:

Linting and Formatting

Biome is used for linting and formatting:

# Check for issues
bun run lint

# Auto-fix issues
bun run format

TypeScript

  • Strict mode is enabled
  • All code must be fully typed
  • Avoid any types unless absolutely necessary

Testing

  • Comprehensive test suite required for all changes
  • Maintain high test coverage
  • Add tests for new features and bug fixes

Architecture

Understanding CosmosQL's architecture helps when contributing:

Core Principles

  1. Zero Dependencies - Only TypeScript, no external runtime dependencies
  2. Type-Level Programming - Leverage TypeScript's type system for schema inference
  3. REST API Direct - Direct HTTP calls to CosmosDB REST API, no SDK wrapper
  4. Bun Runtime Optimized - Built and tested with Bun, works with Node.js

Project Structure

cosmosql/
├── src/
│   ├── client/          # Client creation and configuration
│   ├── container/       # Container schema definitions
│   ├── query/           # Query builder and execution
│   ├── types/           # Type definitions and inference
│   └── utils/           # Utilities and helpers
├── tests/
│   ├── unit/            # Unit tests
│   └── integration/     # Integration tests with real CosmosDB
└── package.json

Key Concepts

  • Schema Definition: TypeScript-only schemas that don't exist at runtime
  • Type Inference: Automatic type inference from schemas
  • Partition Key Enforcement: Compile-time enforcement via TypeScript
  • Query Builder: Type-safe SQL query generation

Making Changes

Workflow

  1. Create a branch:
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix
  1. Make your changes - Write code, add tests

  2. Run tests and linting:

bun test
bun run lint
  1. Commit your changes:
git commit -m "feat: add new feature description"

Follow Conventional Commits format:

  • feat: - New feature
  • fix: - Bug fix
  • docs: - Documentation changes
  • test: - Test additions/changes
  • refactor: - Code refactoring
  • perf: - Performance improvements
  1. Push and create a PR:
git push origin feature/your-feature-name

Pull Request Guidelines

  • Clear description of what changed and why
  • Reference issues if fixing a bug or feature request
  • Add tests for new features or bug fixes
  • Update documentation if API changes or new features added
  • Ensure all tests pass and linting is clean

Testing Requirements

  • New features must include unit tests
  • Integration tests for database operations
  • Edge cases should be covered
  • Maintain or improve test coverage

Areas for Contribution

High Priority

  • Bug fixes - Report and fix bugs
  • Documentation - Improve clarity and completeness
  • Performance - Optimize queries and operations
  • Type safety - Enhance type inference and error messages

Feature Ideas

  • Aggregation functions (count, sum, avg, etc.)
  • Cursor-based pagination
  • Batch operations across partitions
  • Additional query operators

Documentation

  • Code examples
  • Best practices guides
  • Migration guides
  • API reference improvements

Getting Help

If you need help:

  1. Check existing issues on GitHub
  2. Ask questions in discussions
  3. Join Discord community (if available)
  4. Read existing code to understand patterns

License

CosmosQL is licensed under the MIT License. By contributing, you agree that your contributions will be licensed under the same license.


Thank you for contributing to CosmosQL! Your efforts help make CosmosDB development safer and more enjoyable for everyone.

FAQ

Frequently asked questions about CosmosQL

View on separate page →

Quick answers to common questions. For detailed guides, see Quickstart or Patterns & Use Cases.

Quick Decisions

Should I use CosmosQL?
Use CosmosQL if: TypeScript ✅ | Need type safety ✅ | Want minimal dependencies ✅
Use Azure SDK if: Need change feed/stored procedures ✅ | JavaScript only ✅

Can I migrate existing databases?
Yes. CosmosQL works with existing CosmosDB databases. No data migration needed—just define schemas matching your current documents.

Can I use both libraries?
Yes. CosmosQL and Azure SDK coexist—use CosmosQL for CRUD, Azure SDK for advanced features.

Features

What's supported?

  • ✅ CRUD operations (create, read, update, delete)
  • ✅ Partition-scoped and cross-partition queries
  • ✅ Point reads, filtering, sorting, pagination
  • ✅ Bulk operations within partitions
  • ✅ Raw SQL queries
  • ❌ Change feed processors
  • ❌ Stored procedures
  • ❌ Graph/MongoDB APIs

See Limitations for complete list.

Language & Runtime

JavaScript support?
No. CosmosQL requires TypeScript. The value is compile-time type safety.

Node.js version?
Node.js 18+. Uses modern built-in APIs.

Serverless?
Yes. Zero dependencies make CosmosQL ideal for Lambda, Azure Functions, Edge Runtime.

Performance

Overhead vs REST?
~1-2%. Direct REST API calls with compile-time type safety.

vs Azure SDK?
CosmosQL is typically faster due to zero runtime overhead. Azure SDK has some bulk operation optimizations.

How much does a query cost?

  • Point read: ~1 RU
  • Partition query: ~3-5 RU
  • Cross-partition: ~50-100+ RU

Schema Changes

Adding fields?
Update your schema. Existing documents work fine—new fields are optional.

Removing fields?
Treat as optional in schema. Can't truly remove from existing documents. Consider versioning.

Changing field types?
Version documents or write migration scripts. See Migrating from Azure SDK for strategies.

Migration

From Azure SDK?
Gradual migration recommended. Use CosmosQL for new features, keep SDK for advanced features. See Migrating from Azure SDK.

Schema design?
Start with Core Concepts to understand partition keys. Then see Patterns & Use Cases for examples.

Production

Production ready?
Yes. Stable API, 249 tests, 100% coverage. Used in production by multiple teams.

Error handling?
Standard try/catch with CosmosError class. See Error Handling for examples.

Support?
GitHub issues, discussions, and documentation. Maintainers are responsive.

Comparison

vs Prisma?
CosmosQL is CosmosDB-specific with partition key enforcement. Prisma supports many databases.

vs Mongoose?
CosmosQL is TypeScript-native, compile-time validation. Mongoose is MongoDB with runtime validation.

See Limitations for complete comparison table.

Need More Help?

Limitations

Current limitations of CosmosQL and planned features

View on separate page →

CosmosQL focuses on core CRUD operations and common query patterns that cover the majority of application use cases. This document outlines what's currently supported, what's missing, and workarounds for common scenarios.

Current Version Status

Production Ready: ✅ Yes - Core operations are stable and well-tested

Test Coverage: Comprehensive test suite covering all core functionality

Architecture:

  • Zero dependencies - Only TypeScript required
  • Type-level programming - Schema inference and type safety
  • REST API direct - No SDK wrapper, minimal overhead
  • Bun runtime - Optimized for Bun 1.0+ and Node.js 18+

Missing Features

1. Advanced Pagination

CosmosQL currently only supports offset-based pagination using take and skip. Cursor-based pagination and continuation tokens are not yet supported.

Current Support:

// Offset pagination (LIMIT/OFFSET)
const page1 = await db.posts.findMany({
  partitionKey: 'user_123',
  take: 20,
  skip: 0
});

const page2 = await db.posts.findMany({
  partitionKey: 'user_123',
  take: 20,
  skip: 20
});

Limitations:

  • No cursor-based pagination
  • No continuation token support
  • Cross-partition queries with large offsets can be expensive

Workaround: For large datasets, consider:

  1. Partition-scoped queries - Stay within partitions to minimize costs
  2. Date/time-based filtering - Use timestamp filters instead of large offsets
  3. Manual continuation tokens - Use raw SQL queries with continuation tokens (advanced)

Future Plans: Cursor-based pagination and continuation token support are planned.

2. Transaction Support

CosmosQL does not support:

  • Multi-document transactions
  • Batch operations beyond createMany (which requires same partition key)
  • Transaction rollback capabilities
  • Cross-partition transactions

Current Support:

// Single-partition batch operations only
await db.posts.createMany({
  data: [
    { id: 'post_1', userId: 'user_123', title: 'Post 1' },
    { id: 'post_2', userId: 'user_123', title: 'Post 2' }
  ],
  partitionKey: 'user_123' // All documents must share partition key
});

Limitations:

  • Cannot atomically update multiple documents across partitions
  • No rollback support
  • Limited to operations within a single partition

Workaround: For multi-document operations:

  1. Design for single-partition operations - Structure data so related documents share partition keys
  2. Use application-level transactions - Implement compensating actions for failures
  3. Use Azure SDK for transactions - For complex scenarios, use the official SDK alongside CosmosQL

Future Plans: Transaction support is under consideration for future releases.

3. Cross-Partition Query Limitations

Azure CosmosDB Restriction: Cross-partition queries are limited on empty containers. This is a CosmosDB limitation, not a CosmosQL limitation.

When attempting cross-partition queries on empty containers, CosmosDB returns an error. This is by design to prevent expensive operations on empty data.

Workaround: Ensure containers have at least one document before running cross-partition queries, or use partition-scoped queries when possible.

4. Advanced Query Features

The following CosmosDB features are not yet supported:

  • Geospatial queries - Spatial indexing is supported in schema definition but full geospatial queries require raw SQL
  • Full-text search - Integration with Azure Cognitive Search (not supported via REST API)
  • JOIN operations - Not applicable to document databases, but no support for array joins
  • Stored procedures - Cannot execute stored procedures (REST API focus)
  • User-defined functions (UDFs) - Cannot use UDFs in queries (REST API focus)

Workaround: Use raw SQL queries for features not supported by the query builder:

// Geospatial example (when supported via raw SQL)
const result = await db.locations.query({
  sql: `
    SELECT * FROM c 
    WHERE ST_DISTANCE(c.location, {
      'type': 'Point',
      'coordinates': [@lng, @lat]
    }) < @distance
  `,
  parameters: [
    { name: '@lng', value: -122.4194 },
    { name: '@lat', value: 37.7749 },
    { name: '@distance', value: 5000 }
  ]
});

Future Plans: Advanced query features may be added based on community demand.

5. Performance Optimizations

Missing performance-related features:

  • Query result caching - No built-in caching layer
  • Query plan analysis - No automatic query optimization suggestions
  • Automatic indexing suggestions - No recommendations for index optimization
  • Connection pool management - Basic pooling exists, but limited configuration

Workaround:

  1. Implement caching at application level - Use Redis or in-memory caching for frequently accessed data
  2. Monitor RU usage - Track Request Unit consumption in Azure Portal
  3. Optimize partition keys - Ensure good partition key selection (documented in Performance Guide)
  4. Use point reads - Prefer findUnique over findMany when querying single documents

Future Plans: Performance monitoring and optimization tools may be added.

6. Change Feed

CosmosQL does not support:

  • Change feed processors
  • Real-time change notifications
  • Document change tracking

Workaround: Use the official Azure SDK for change feed processing:

// Using Azure SDK for change feed
import { CosmosClient } from '@azure/cosmos';

const client = new CosmosClient(connectionString);
const container = client.database('mydb').container('users');

// Use SDK for change feed
const changeFeedProcessor = container.items
  .changeFeed({
    startFromBeginning: false
  })
  .on('change', (changes) => {
    // Handle changes
  });

Future Plans: Change feed support is being considered for future releases.

7. Multi-Database Operations

CosmosQL works with a single database per client instance. Cross-database operations are not supported.

Workaround: Create multiple client instances:

const productionDb = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'production',
  mode: 'verify'
}).withContainers({ users, posts });

const analyticsDb = await createClient({
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  database: 'analytics',
  mode: 'verify'
}).withContainers({ events, metrics });

Feature Comparison

FeatureCosmosQLAzure SDKStatus
Type Safety✅ Full❌ LimitedCosmosQL advantage
CRUD Operations✅ Complete✅ CompleteEquivalent
Aggregations✅ Type-safe✅ NativeEquivalent
Transactions⚠️ Single partition✅ Full supportSDK advantage
Change Feed❌ Not supported✅ SupportedSDK advantage
Database Management✅ Full management✅ Full managementEquivalent
Zero Dependencies✅ Yes❌ 50+ packagesCosmosQL advantage
Query Builder✅ Type-safe❌ SQL stringsCosmosQL advantage

When to Use CosmosQL vs Azure SDK

Use CosmosQL when:

  • ✅ You prioritize type safety and developer experience
  • ✅ You need zero dependencies for serverless/microservices
  • ✅ Your use cases fit core CRUD operations
  • ✅ You want compile-time guarantees for partition keys
  • ✅ You're building new TypeScript applications
  • ✅ You need database and container management (with auto-create, verify, or skip modes)

Use Azure SDK when:

  • ✅ You need advanced features (change feed, stored procedures)
  • ✅ You need cross-partition transactions
  • ✅ You're working with JavaScript (not TypeScript)
  • ✅ You need geospatial queries or full-text search

Use Both:

You can use CosmosQL alongside the Azure SDK for different use cases within the same project. They don't interfere with each other and work with the same database.

Roadmap

High Priority:

  1. ✅ Core CRUD operations (Completed)
  2. ✅ Type-safe query builder (Completed)
  3. ✅ Aggregation functions (Completed)
  4. 🔄 Cursor pagination (Planned)
  5. 🔄 Batch operations (Planned)

Medium Priority:

  1. ✅ Container management APIs (Completed)
  2. ⏳ Advanced querying (Geospatial, full-text) (Future)
  3. ⏳ Performance monitoring (Future)
  4. ⏳ Migration support (Future)

Lower Priority:

  1. ⏳ Multiple database support (Future)
  2. ⏳ Advanced connection pooling (Future)
  3. ⏳ Circuit breaker patterns (Future)

Reporting Issues

If you encounter limitations that impact your use case:

  1. Check existing issues on GitHub
  2. Create a feature request with use case details
  3. Contribute - Pull requests welcome!
  4. Use workarounds - Many limitations have documented workarounds

Getting Help

For questions about limitations or missing features:

  • Check the FAQ for common questions
  • Review Patterns & Use Cases for workarounds
  • Open a GitHub issue for specific feature requests
  • Join the community Discord for discussions

Migrating from Azure SDK

Learn how to migrate from the official Azure CosmosDB SDK to CosmosQL

View on separate page →

Migration from Azure SDK

If you're currently using the official Azure CosmosDB SDK (@azure/cosmos), this guide will help you migrate to CosmosQL. The migration is straightforward—CosmosQL works with existing databases and data, so you can adopt it incrementally.

Why Migrate?

The Azure SDK is powerful, but CosmosQL provides significant advantages:

Key Benefits

  • Type Safety: Compile-time guarantees instead of runtime errors
  • Better Developer Experience: Cleaner API, autocomplete, fewer mistakes
  • Performance: Zero runtime overhead, direct REST API calls
  • Cost Prevention: Type system prevents expensive cross-partition queries
  • Minimal Dependencies: No SDK bloat (50+ packages → zero dependencies)

Migration Strategy

You don't need to migrate everything at once. Here's a recommended approach:

Incremental Migration

CosmosQL and the Azure SDK can coexist in the same project—they use the same database and don't interfere. This allows you to migrate gradually without disruption.

  1. Start with New Code: Use CosmosQL for new features and endpoints
  2. Migrate High-Traffic Paths: Convert frequently-used operations to CosmosQL
  3. Gradual Replacement: Migrate remaining code incrementally
  4. Remove SDK: Once everything is migrated, remove the Azure SDK dependency

Basic Migration Examples

Creating a Client

Before (Azure SDK):

import { CosmosClient } from '@azure/cosmos';

const client = new CosmosClient(connectionString);
const database = client.database('mydb');
const container = database.container('users');

After (CosmosQL):

import { createClient, container, field } from 'cosmosql';

// Define your schema
const users = container('users', {
  id: field.string(),
  email: field.string(),
  name: field.string()
}).partitionKey('email');

// Create typed client (async - validates/creates containers)
const db = await createClient({
  connectionString,
  database: 'mydb',
  mode: 'auto-create'
}).withContainers({ users });

Key Differences:

Important Changes

  • CosmosQL requires schema definitions (for type safety)
  • Client creation is a one-time setup per database
  • db.users is fully typed, container is not

Querying Documents

Before (Azure SDK):

const { resources } = await container.items
  .query({
    query: 'SELECT * FROM c WHERE c.id = @id AND c.email = @email',
    parameters: [
      { name: '@id', value: 'user_123' },
      { name: '@email', value: 'john@example.com' }
    ]
  })
  .fetchAll();

const user = resources[0]; // No type safety
// Type: any - could be undefined, wrong shape, etc.

After (CosmosQL):

// Point read (most efficient)
const user = await db.users.findUnique({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  }
});

// Type: { id: string; email: string; name: string } | null
// TypeScript knows the exact shape and nullability

Benefits:

  • Type-safe: TypeScript knows the exact return type
  • Simpler: No SQL strings, no parameter arrays
  • Safer: Can't forget partition key (required by types)
  • More efficient: findUnique uses point reads (faster than queries)

Querying Multiple Documents

Before (Azure SDK):

const { resources } = await container.items
  .query({
    query: 'SELECT * FROM c WHERE c.isActive = @active ORDER BY c.createdAt DESC',
    parameters: [{ name: '@active', value: true }]
  })
  .fetchAll();

// No type safety, no partition key enforcement

After (CosmosQL):

// Partition-scoped query (efficient)
const activeUsers = await db.users.findMany({
  partitionKey: 'john@example.com', // Required!
  where: {
    isActive: true
  },
  orderBy: {
    createdAt: 'desc'
  }
});

// Type: Array<{ id: string; email: string; ... }>
// Partition key is enforced at compile time

Creating Documents

Before (Azure SDK):

const { resource: newUser } = await container.items.create({
  id: 'user_123',
  email: 'john@example.com',
  name: 'John Doe'
});

// No type checking - could have typos, missing fields, etc.

After (CosmosQL):

const newUser = await db.users.create({
  data: {
    id: 'user_123',
    email: 'john@example.com',
    name: 'John Doe'
    // TypeScript ensures all required fields are present
    // Autocomplete helps you discover available fields
  }
});

// Type: { id: string; email: string; name: string }
// Fully typed result

Updating Documents

Before (Azure SDK):

const { resource: updated } = await container
  .item('user_123', 'john@example.com') // ID and partition key
  .replace({
    id: 'user_123',
    email: 'john@example.com',
    name: 'John Updated',
    age: 30 // Could forget fields, wrong types, etc.
  });

After (CosmosQL):

// Update specific fields (partial update)
const updated = await db.users.update({
  where: {
    id: 'user_123',
    email: 'john@example.com'
  },
  data: {
    name: 'John Updated',
    age: 30
    // Only include fields you want to update
    // TypeScript ensures types match schema
  }
});

// Type: { id: string; email: string; name: string; age: number }

Deleting Documents

Before (Azure SDK):

await container
  .item('user_123', 'john@example.com')
  .delete();

After (CosmosQL):

await db.users.delete({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  }
});

// Type-safe: can't delete without partition key

Handling Common Patterns

Error Handling

Before (Azure SDK):

try {
  await container.items.create(user);
} catch (error: any) {
  if (error.code === 409) {
    // Conflict
  } else if (error.code === 429) {
    // Rate limited
  }
  // Error types are loose, easy to miss cases
}

After (CosmosQL):

import { CosmosError } from 'cosmosql';

try {
  await db.users.create({ data: user });
} catch (error) {
  if (error instanceof CosmosError) {
    switch (error.code) {
      case 409: // Conflict
      case 429: // Rate limited
      // Type-safe error handling
    }
  }
}

Cross-Partition Queries

Before (Azure SDK):

// Easy to accidentally do expensive cross-partition query
const { resources } = await container.items
  .query('SELECT * FROM c WHERE c.isActive = true')
  .fetchAll();
// This scans ALL partitions - expensive!

After (CosmosQL):

// Type system prevents accidental cross-partition queries
const users = await db.users.findMany({
  enableCrossPartitionQuery: true, // Must explicitly opt in
  where: { isActive: true }
});

// You're forced to think about the cost

Migration Checklist

When migrating code from Azure SDK to CosmosQL:

  • Define schemas for all containers you use
  • Identify partition keys for each container
  • Replace client creation with CosmosQL client
  • Convert queries from SQL strings to query builder
  • Update error handling to use CosmosError
  • Test thoroughly in staging before production
  • Monitor RU usage to ensure queries are efficient
  • Update types throughout your codebase
  • Remove Azure SDK once migration is complete

Common Pitfalls

Forgetting Partition Keys: CosmosQL requires partition keys in where clauses. This is intentional—it prevents expensive cross-partition queries. Make sure you always provide partition keys.

Schema Mismatches: Your CosmosQL schema must match your actual document structure. Review existing documents to ensure field names and types align.

Type Assertions: Avoid using as any to bypass type checking. If you need to, it's often a sign your schema needs adjustment.

Gradual Migration: Don't try to migrate everything at once. Start small, test thoroughly, then expand.

Getting Help

If you run into issues during migration:

  1. Check the FAQ for common questions
  2. Review the API Reference for method details
  3. Look at Patterns & Use Cases for real-world examples
  4. Open a GitHub issue if you find bugs or need clarification

The migration process is straightforward, and you'll quickly see the benefits of type safety and better developer experience.

Aggregation Methods

Complete reference for count, aggregate, groupBy, and convenience methods

View on separate page →

Aggregation Operations

count(options)

Count documents matching criteria.

const count = await db.users.count({
  partitionKey: 'john@example.com',
  where: { isActive: true }
});
// Returns: number

aggregate(options)

Perform aggregation operations (count, sum, avg, min, max).

const stats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  where: { status: 'completed' },
  _count: true,
  _sum: { amount: true, tax: true },
  _avg: { amount: true },
  _min: { createdAt: true },
  _max: { amount: true }
});
// Returns: {
//   _count: number,
//   _sum: { amount: number | null, tax: number | null },
//   _avg: { amount: number | null },
//   _min: { createdAt: Date | null },
//   _max: { amount: number | null }
// }

groupBy(options)

Group data and perform aggregations on each group.

const salesByCategory = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _count: true,
  _sum: { amount: true },
  _avg: { amount: true },
  orderBy: { _sum_amount: 'desc' },
  take: 10
});
// Returns: Array<{
//   category: string,
//   _count: number,
//   _sum: { amount: number | null },
//   _avg: { amount: number | null }
// }>

Convenience Methods

Quick aggregations for single fields:

// Sum
const totalRevenue = await db.orders.sum('amount', {
  partitionKey: 'customer-123',
  where: { status: 'completed' }
});
// Returns: number | null

// Average
const avgAge = await db.users.avg('age', {
  partitionKey: 'tenant-1'
});
// Returns: number | null

// Minimum
const minPrice = await db.products.min('price', {
  partitionKey: 'category-electronics'
});
// Returns: number | null

// Maximum
const maxPrice = await db.products.max('price', {
  partitionKey: 'category-electronics'
});
// Returns: number | null

Aggregation Options

partitionKey - Scope to partition

partitionKey: 'john@example.com'        // Single value
partitionKey: ['tenant_1', 'org_a']      // Composite partition key (array)

where - Filter conditions

where: {
  status: 'completed',
  amount: { gte: 100 },
  createdAt: { gte: new Date('2024-01-01') }
}

enableCrossPartitionQuery - Allow cross-partition aggregations

enableCrossPartitionQuery: true // Required for groupBy across partitions

Aggregation Fields

  • _count: true - Count documents
  • _sum: { field: true } - Sum numeric fields
  • _avg: { field: true } - Average numeric fields
  • _min: { field: true } - Minimum value
  • _max: { field: true } - Maximum value

See Aggregations Guide for complete documentation with examples.

API Reference

Complete API reference for CosmosQL field types, container methods, and query options

View on separate page →

Complete reference for CosmosQL's API, including field types, container methods, query operators, and configuration options.

Field Types

CosmosQL provides a fluent API for defining field types in your schema:

// Primitive types
field.string()           // String type
field.number()           // Number type
field.boolean()          // Boolean type
field.date()             // Date type (stored as ISO strings)

// Complex types
field.array(type)        // Array of any field type
field.object(schema)     // Nested object with its own schema

// Modifiers
.optional()              // Makes field optional (can be undefined)
.default(value)          // Provides default value for optional fields

Example Schema with All Field Types:

const users = container('users', {
  // Required fields
  id: field.string(),
  email: field.string(),
  name: field.string(),
  age: field.number(),
  isActive: field.boolean(),
  createdAt: field.date(),
  
  // Optional fields
  bio: field.string().optional(),
  lastLoginAt: field.date().optional(),
  
  // Fields with defaults
  viewCount: field.number().default(0),
  settings: field.object({
    theme: field.string().default('light'),
    notifications: field.boolean().default(true)
  }).optional(),
  
  // Arrays
  tags: field.array(field.string()),
  scores: field.array(field.number()),
  
  // Nested objects
  profile: field.object({
    website: field.string().optional(),
    location: field.string().optional()
  }).optional()
}).partitionKey('email');

Container Configuration

Containers can be configured with throughput, indexing policies, and more:

const posts = container('posts', {
  id: field.string(),
  userId: field.string(),
  title: field.string(),
  content: field.string(),
  tags: field.array(field.string()),
  createdAt: field.date()
})
.partitionKey('userId')
.throughput(400)                    // Optional: Set RU/s (Request Units per second)
.indexing({                         // Optional: Configure indexing policy
  automatic: true,
  includedPaths: [
    { path: '/title/?' },
    { path: '/tags/[]/?' }
  ],
  excludedPaths: [
    { path: '/content/?' }
  ],
  compositeIndexes: [
    [
      { path: '/createdAt', order: 'ascending' },
      { path: '/userId', order: 'ascending' }
    ]
  ]
});

Container Configuration Options:

  • .partitionKey(field) - Required: Specifies the partition key field
  • .throughput(ru) - Optional: Sets RU/s for the container (default: auto-scale)
  • .indexing(policy) - Optional: Configures indexing policy

Indexing Policy Options:

indexing: {
  automatic: boolean;              // Enable automatic indexing
  includedPaths?: Array<{          // Paths to index
    path: string;                  // Path pattern (e.g., '/title/?')
    indexes?: Array<{
      kind: 'Range' | 'Hash';      // Index kind
      precision?: number;           // Precision for range indexes
      dataType: 'String' | 'Number' // Data type
    }>
  }>;
  excludedPaths?: Array<{          // Paths to exclude from indexing
    path: string;
  }>;
  compositeIndexes?: Array<        // Composite indexes for multi-field sorting
    Array<{
      path: string;
      order: 'ascending' | 'descending';
    }>
  >;
  spatialIndexes?: Array<{         // Spatial indexes (advanced)
    path: string;
    types: Array<'Point' | 'LineString' | 'Polygon' | 'MultiPolygon'>;
  }>;
}

Container Methods

Query Operations

findUnique(options)

Retrieves a single document by ID and partition key. This is the most efficient operation (point read).

const user = await db.users.findUnique({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  },
  select: {
    name: true,
    email: true
  }
});
// Returns: { name: string; email: string } | null

findMany(options)

Queries multiple documents with filtering, sorting, and pagination. Optionally include aggregations to get both data and statistics in a single query.

// Without aggregations
const users = await db.users.findMany({
  partitionKey: 'john@example.com',
  where: {
    isActive: true,
    age: { gte: 18 }
  },
  select: { id: true, name: true },
  orderBy: { age: 'desc' },
  take: 10,
  skip: 0
});
// Returns: Array<{ id: string; name: string }>

// With aggregations
const result = await db.users.findMany({
  partitionKey: 'john@example.com',
  where: { isActive: true },
  aggregate: {
    _count: true,
    _avg: { age: true },
    _sum: { score: true },
    _min: { createdAt: true },
    _max: { createdAt: true }
  }
});
// Returns: { 
//   data: User[],
//   _count: number,
//   _avg: { age: number | null },
//   _sum: { score: number | null },
//   _min: { createdAt: Date | null },
//   _max: { createdAt: Date | null }
// }

See Aggregations Guide for complete documentation.

query<T>(options)

Execute raw SQL queries for advanced use cases.

const result = await db.users.query<{ count: number }>({
  sql: 'SELECT COUNT(1) as count FROM c WHERE c.isActive = true',
  parameters: [
    { name: '@active', value: true }
  ],
  partitionKey: 'john@example.com' // Optional: for partition-scoped queries
});
// Returns: Array<T>

Write Operations

create(options)

Creates a single document.

const user = await db.users.create({
  data: {
    id: 'user_123',
    email: 'john@example.com',
    name: 'John Doe',
    age: 30,
    createdAt: new Date()
    // isActive will use default value if defined
  }
});
// Returns: Full user object (fully typed)

createMany(options)

Creates multiple documents in a single operation. All documents must share the same partition key.

await db.users.createMany({
  data: [
    { id: 'user_1', email: 'user1@test.com', name: 'User 1', age: 25 },
    { id: 'user_2', email: 'user2@test.com', name: 'User 2', age: 30 }
  ],
  partitionKey: 'shared@email.com' // All must share partition key
});

update(options)

Updates a single document by ID and partition key.

await db.users.update({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  },
  data: {
    age: 31,
    name: 'John Smith'
  }
});
// Returns: Updated user object

upsert(options)

Updates a document if it exists, creates it if it doesn't.

await db.users.upsert({
  where: {
    id: 'user_123',
    email: 'john@example.com'
  },
  create: {
    id: 'user_123',
    email: 'john@example.com',
    name: 'New User',
    age: 25
  },
  update: {
    age: 26,
    name: 'Updated Name'
  }
});

delete(options)

Deletes a single document by ID and partition key.

await db.users.delete({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  }
});

Aggregation Operations

count(options)

Count documents matching criteria.

const count = await db.users.count({
  partitionKey: 'john@example.com',
  where: { isActive: true }
});
// Returns: number

aggregate(options)

Perform aggregation operations (count, sum, avg, min, max).

const stats = await db.orders.aggregate({
  partitionKey: 'customer-123',
  where: { status: 'completed' },
  _count: true,
  _sum: { amount: true, tax: true },
  _avg: { amount: true },
  _min: { createdAt: true },
  _max: { amount: true }
});
// Returns: {
//   _count: number,
//   _sum: { amount: number | null, tax: number | null },
//   _avg: { amount: number | null },
//   _min: { createdAt: Date | null },
//   _max: { amount: number | null }
// }

groupBy(options)

Group data and perform aggregations on each group.

const salesByCategory = await db.sales.groupBy({
  by: 'category',
  enableCrossPartitionQuery: true,
  _count: true,
  _sum: { amount: true },
  _avg: { amount: true },
  orderBy: { _sum_amount: 'desc' },
  take: 10
});
// Returns: Array<{
//   category: string,
//   _count: number,
//   _sum: { amount: number | null },
//   _avg: { amount: number | null }
// }>

Convenience Methods:

// Quick aggregations for single fields
const totalRevenue = await db.orders.sum('amount', {
  partitionKey: 'customer-123',
  where: { status: 'completed' }
});
// Returns: number | null

const avgAge = await db.users.avg('age', {
  partitionKey: 'tenant-1'
});
// Returns: number | null

const minPrice = await db.products.min('price', {
  partitionKey: 'category-electronics'
});
// Returns: number | null

const maxPrice = await db.products.max('price', {
  partitionKey: 'category-electronics'
});
// Returns: number | null

See Aggregations Guide for complete documentation.

Query Operators

String Operators

where: {
  name: { contains: 'John' },      // CONTAINS(c.name, 'John')
  email: { startsWith: 'user' },   // STARTSWITH(c.email, 'user')
  bio: { endsWith: 'developer' }    // ENDSWITH(c.bio, 'developer')
}

Number Operators

where: {
  age: { gt: 21 },     // c.age > 21 (greater than)
  score: { gte: 85 },  // c.score >= 85 (greater than or equal)
  rating: { lt: 5 },   // c.rating < 5 (less than)
  count: { lte: 100 },  // c.count <= 100 (less than or equal)
  // Multiple conditions (range)
  age: { gte: 18, lte: 65 }  // 18 <= c.age <= 65
}

Array Operators

where: {
  tags: { contains: 'javascript' },              // CONTAINS(c.tags, 'javascript')
  skills: { containsAny: ['react', 'node'] },    // Any element matches (planned)
  permissions: { containsAll: ['read', 'write'] } // All elements must match (planned)
}

Note: containsAny and containsAll are planned features. Currently, use contains for single element checks or raw SQL queries for complex array operations.

Boolean Operators

where: {
  isActive: true,           // Exact match
  isActive: { equals: true }, // Explicit equals (same as above)
  isVerified: false
}

Date Operators

where: {
  createdAt: { gte: new Date('2024-01-01') },  // Greater than or equal
  publishedAt: { lt: new Date() },              // Less than
  lastLoginAt: { lte: new Date() }              // Less than or equal
}

Nested Object Queries

where: {
  profile: {
    settings: {
      theme: 'dark',
      notifications: true
    }
  }
}

Combining Operators

where: {
  // Multiple conditions (AND logic)
  isActive: true,
  age: { gte: 18, lte: 65 },
  name: { startsWith: 'John' },
  tags: { contains: 'developer' }
}

Query Options

where - Filter conditions

where: {
  // Exact match
  isActive: true,
  
  // Comparisons
  age: { gte: 18, lte: 65 },
  
  // String operations
  name: { startsWith: 'John' },
  bio: { contains: 'developer' },
  
  // Array operations
  tags: { contains: 'javascript' },
  
  // Nested objects
  profile: {
    settings: { theme: 'dark' }
  }
}

select - Choose fields to return

select: {
  id: true,
  name: true,
  email: true,
  profile: {
    website: true
  }
}
// Returns only selected fields

orderBy - Sort results

orderBy: {
  age: 'desc',      // Sort by age descending
  name: 'asc'       // Then by name ascending
}

take - Limit results (maximum number)

take: 10 // Return maximum 10 documents

skip - Offset results

skip: 20 // Skip first 20 documents (for pagination)

partitionKey - Scope query to partition

partitionKey: 'john@example.com'        // Single value
partitionKey: ['tenant_1', 'org_a']      // Composite partition key (array)

enableCrossPartitionQuery - Allow expensive cross-partition queries

enableCrossPartitionQuery: true // Must explicitly opt-in

Client Configuration

Connection Options:

const db = await createClient({
  // Option 1: Connection string (recommended)
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  
  // Option 2: Explicit endpoint + key
  endpoint: 'https://myaccount.documents.azure.com:443/',
  key: process.env.COSMOS_KEY!,
  
  // Required: Database name
  database: 'myapp',
  
  // Optional: Container validation mode
  mode: 'auto-create', // 'auto-create' | 'verify' | 'skip'
  
  // Optional: Retry configuration
  retryOptions: {
    maxRetries: 3,        // Default: 3
    initialDelay: 100,    // Default: 100ms
    maxDelay: 5000        // Default: 5000ms
  }
}).withContainers({ users, posts });

Configuration Options:

OptionTypeDefaultDescription
endpointstring-Azure CosmosDB endpoint URL
keystring-Azure CosmosDB master key
connectionStringstring-Full connection string (alternative to endpoint+key)
databasestringRequiredDatabase name
modeContainerMode'verify'Container management mode
retryOptionsobjectSee belowRetry configuration

Container Modes:

  • 'auto-create' - Automatically creates database and containers if they don't exist
  • 'verify' - Validates that database and containers exist with correct configuration (production default)
  • 'skip' - Skips all checks for maximum performance

Retry Options:

retryOptions: {
  maxRetries?: number;    // Default: 3
  initialDelay?: number;  // Default: 100ms
  maxDelay?: number;      // Default: 5000ms
}

Note: Client creation is async. It validates and optionally creates containers based on the mode parameter. See Getting Started Guide for detailed mode documentation.

Container Registration:

.withContainers({
  users,
  posts,
  comments
  // Add all your container schemas here
})

Database Operations

Container Management

listOrphanedContainers()

Lists containers in the database that are not registered in your schema. Useful for identifying containers that may need cleanup.

const orphaned = await db.listOrphanedContainers();
// Returns: string[] - Array of container names not in schema

deleteContainers(names)

Deletes specific containers by name. Use with caution as this permanently removes containers and all their data.

await db.deleteContainers(['old_container', 'temp_container']);

pruneContainers(options)

Removes all orphaned containers (containers not in your schema). Requires explicit confirmation.

// Prune all orphaned containers (requires confirmation)
await db.pruneContainers({ confirm: true });

// Get list of containers that would be pruned without deleting
const orphaned = await db.listOrphanedContainers();
console.log('Would delete:', orphaned);

⚠️ Warning: These operations permanently delete containers and all their data. Always back up important data before using these methods.


This documentation provides comprehensive coverage of CosmosQL's API. The library enforces CosmosDB best practices at the type level, preventing costly mistakes before they reach production.

Next Steps:

  1. Install CosmosQL: npm install cosmosql

  2. Define your schema using the field types above

  3. Start querying with full type safety

For questions or issues, visit the GitHub repository or Discord community.

Client & Configuration

Complete reference for creating and configuring CosmosQL clients

View on separate page →

Client Creation

Create a CosmosQL client with connection options:

const db = await createClient({
  // Option 1: Connection string (recommended)
  connectionString: process.env.COSMOS_CONNECTION_STRING!,
  
  // Option 2: Explicit endpoint + key
  endpoint: 'https://myaccount.documents.azure.com:443/',
  key: process.env.COSMOS_KEY!,
  
  // Required: Database name
  database: 'myapp',
  
  // Optional: Container validation mode
  mode: 'auto-create', // 'auto-create' | 'verify' | 'skip'
  
  // Optional: Retry configuration
  retryOptions: {
    maxRetries: 3,        // Default: 3
    initialDelay: 100,    // Default: 100ms
    maxDelay: 5000        // Default: 5000ms
  }
}).withContainers({ users, posts });

Configuration Options

OptionTypeDefaultDescription
endpointstring-Azure CosmosDB endpoint URL
keystring-Azure CosmosDB master key
connectionStringstring-Full connection string (alternative to endpoint+key)
databasestringRequiredDatabase name
modeContainerMode'verify'Container management mode
retryOptionsobjectSee belowRetry configuration

Container Modes

  • 'auto-create' - Automatically creates database and containers if they don't exist
  • 'verify' - Validates that database and containers exist with correct configuration (production default)
  • 'skip' - Skips all checks for maximum performance

Retry Options

retryOptions: {
  maxRetries?: number;    // Default: 3
  initialDelay?: number;  // Default: 100ms
  maxDelay?: number;      // Default: 5000ms
}

Note: Client creation is async. It validates and optionally creates containers based on the mode parameter. See Getting Started for detailed mode documentation.

Container Registration

.withContainers({
  users,
  posts,
  comments
  // Add all your container schemas here
})

Database Operations

Container Management

listOrphanedContainers()

Lists containers in the database that are not registered in your schema. Useful for identifying containers that may need cleanup.

const orphaned = await db.listOrphanedContainers();
// Returns: string[] - Array of container names not in schema

deleteContainers(names)

Deletes specific containers by name. Use with caution as this permanently removes containers and all their data.

await db.deleteContainers(['old_container', 'temp_container']);

pruneContainers(options)

Removes all orphaned containers (containers not in your schema). Requires explicit confirmation.

// Prune all orphaned containers (requires confirmation)
await db.pruneContainers({ confirm: true });

// Get list of containers that would be pruned without deleting
const orphaned = await db.listOrphanedContainers();
console.log('Would delete:', orphaned);

⚠️ Warning: These operations permanently delete containers and all their data. Always back up important data before using these methods.

Query Methods

Complete reference for querying documents with findUnique, findMany, and query

View on separate page →

Query Operations

findUnique(options)

Retrieves a single document by ID and partition key. This is the most efficient operation (point read).

const user = await db.users.findUnique({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  },
  select: {
    name: true,
    email: true
  }
});
// Returns: { name: string; email: string } | null

findMany(options)

Queries multiple documents with filtering, sorting, and pagination. Optionally include aggregations to get both data and statistics in a single query.

// Without aggregations
const users = await db.users.findMany({
  partitionKey: 'john@example.com',
  where: {
    isActive: true,
    age: { gte: 18 }
  },
  select: { id: true, name: true },
  orderBy: { age: 'desc' },
  take: 10,
  skip: 0
});
// Returns: Array<{ id: string; name: string }>

// With aggregations
const result = await db.users.findMany({
  partitionKey: 'john@example.com',
  where: { isActive: true },
  aggregate: {
    _count: true,
    _avg: { age: true },
    _sum: { score: true },
    _min: { createdAt: true },
    _max: { createdAt: true }
  }
});
// Returns: { 
//   data: User[],
//   _count: number,
//   _avg: { age: number | null },
//   _sum: { score: number | null },
//   _min: { createdAt: Date | null },
//   _max: { createdAt: Date | null }
// }

See Aggregations Guide for complete documentation.

query<T>(options)

Execute raw SQL queries for advanced use cases.

const result = await db.users.query<{ count: number }>({
  sql: 'SELECT COUNT(1) as count FROM c WHERE c.isActive = true',
  parameters: [
    { name: '@active', value: true }
  ],
  partitionKey: 'john@example.com' // Optional: for partition-scoped queries
});
// Returns: Array<T>

Query Options

where - Filter conditions

where: {
  // Exact match
  isActive: true,
  
  // Comparisons
  age: { gte: 18, lte: 65 },
  
  // String operations
  name: { startsWith: 'John' },
  bio: { contains: 'developer' },
  
  // Array operations
  tags: { contains: 'javascript' },
  
  // Nested objects
  profile: {
    settings: { theme: 'dark' }
  }
}

select - Choose fields to return

select: {
  id: true,
  name: true,
  email: true,
  profile: {
    website: true
  }
}
// Returns only selected fields

orderBy - Sort results

orderBy: {
  age: 'desc',      // Sort by age descending
  name: 'asc'       // Then by name ascending
}

take - Limit results (maximum number)

take: 10 // Return maximum 10 documents

skip - Offset results

skip: 20 // Skip first 20 documents (for pagination)

partitionKey - Scope query to partition

partitionKey: 'john@example.com'        // Single value
partitionKey: ['tenant_1', 'org_a']      // Composite partition key (array)

enableCrossPartitionQuery - Allow expensive cross-partition queries

enableCrossPartitionQuery: true // Must explicitly opt-in

Query Operators

String Operators

where: {
  name: { contains: 'John' },      // CONTAINS(c.name, 'John')
  email: { startsWith: 'user' },   // STARTSWITH(c.email, 'user')
  bio: { endsWith: 'developer' }    // ENDSWITH(c.bio, 'developer')
}

Number Operators

where: {
  age: { gt: 21 },     // c.age > 21 (greater than)
  score: { gte: 85 },  // c.score >= 85 (greater than or equal)
  rating: { lt: 5 },   // c.rating < 5 (less than)
  count: { lte: 100 },  // c.count <= 100 (less than or equal)
  // Multiple conditions (range)
  age: { gte: 18, lte: 65 }  // 18 <= c.age <= 65
}

Array Operators

where: {
  tags: { contains: 'javascript' },              // CONTAINS(c.tags, 'javascript')
  skills: { containsAny: ['react', 'node'] },    // Any element matches (planned)
  permissions: { containsAll: ['read', 'write'] } // All elements must match (planned)
}

Note: containsAny and containsAll are planned features. Currently, use contains for single element checks or raw SQL queries for complex array operations.

Boolean Operators

where: {
  isActive: true,           // Exact match
  isActive: { equals: true }, // Explicit equals (same as above)
  isVerified: false
}

Date Operators

where: {
  createdAt: { gte: new Date('2024-01-01') },  // Greater than or equal
  publishedAt: { lt: new Date() },              // Less than
  lastLoginAt: { lte: new Date() }              // Less than or equal
}

Nested Object Queries

where: {
  profile: {
    settings: {
      theme: 'dark',
      notifications: true
    }
  }
}

Combining Operators

where: {
  // Multiple conditions (AND logic)
  isActive: true,
  age: { gte: 18, lte: 65 },
  name: { startsWith: 'John' },
  tags: { contains: 'developer' }
}

Schema Definition

Complete reference for field types and container configurations

View on separate page →

Field Types

CosmosQL provides a fluent API for defining field types in your schema:

// Primitive types
field.string()           // String type
field.number()           // Number type
field.boolean()          // Boolean type
field.date()             // Date type (stored as ISO strings)

// Complex types
field.array(type)        // Array of any field type
field.object(schema)     // Nested object with its own schema

// Modifiers
.optional()              // Makes field optional (can be undefined)
.default(value)          // Provides default value for optional fields

Example Schema with All Field Types

const users = container('users', {
  // Required fields
  id: field.string(),
  email: field.string(),
  name: field.string(),
  age: field.number(),
  isActive: field.boolean(),
  createdAt: field.date(),
  
  // Optional fields
  bio: field.string().optional(),
  lastLoginAt: field.date().optional(),
  
  // Fields with defaults
  viewCount: field.number().default(0),
  settings: field.object({
    theme: field.string().default('light'),
    notifications: field.boolean().default(true)
  }).optional(),
  
  // Arrays
  tags: field.array(field.string()),
  scores: field.array(field.number()),
  
  // Nested objects
  profile: field.object({
    website: field.string().optional(),
    location: field.string().optional()
  }).optional()
}).partitionKey('email');

Container Configuration

Containers can be configured with throughput, indexing policies, and more:

const posts = container('posts', {
  id: field.string(),
  userId: field.string(),
  title: field.string(),
  content: field.string(),
  tags: field.array(field.string()),
  createdAt: field.date()
})
.partitionKey('userId')
.throughput(400)                    // Optional: Set RU/s (Request Units per second)
.indexing({                         // Optional: Configure indexing policy
  automatic: true,
  includedPaths: [
    { path: '/title/?' },
    { path: '/tags/[]/?' }
  ],
  excludedPaths: [
    { path: '/content/?' }
  ],
  compositeIndexes: [
    [
      { path: '/createdAt', order: 'ascending' },
      { path: '/userId', order: 'ascending' }
    ]
  ]
});

Container Configuration Options

  • .partitionKey(field) - Required: Specifies the partition key field
  • .throughput(ru) - Optional: Sets RU/s for the container (default: auto-scale)
  • .indexing(policy) - Optional: Configures indexing policy

Indexing Policy Options

indexing: {
  automatic: boolean;              // Enable automatic indexing
  includedPaths?: Array<{          // Paths to index
    path: string;                  // Path pattern (e.g., '/title/?')
    indexes?: Array<{
      kind: 'Range' | 'Hash';      // Index kind
      precision?: number;           // Precision for range indexes
      dataType: 'String' | 'Number' // Data type
    }>
  }>;
  excludedPaths?: Array<{          // Paths to exclude from indexing
    path: string;
  }>;
  compositeIndexes?: Array<        // Composite indexes for multi-field sorting
    Array<{
      path: string;
      order: 'ascending' | 'descending';
    }>
  >;
  spatialIndexes?: Array<{         // Spatial indexes (advanced)
    path: string;
    types: Array<'Point' | 'LineString' | 'Polygon' | 'MultiPolygon'>;
  }>;
}

See Core Concepts for guidance on choosing partition keys.

Type System Deep Dive

Understanding how CosmosQL's type inference works and leveraging it effectively

View on separate page →

How Type Inference Works

CosmosQL leverages TypeScript's powerful type inference to provide compile-time safety:

const users = container('users', {
  id: field.string(),
  email: field.string(),
  name: field.string().optional(),
  age: field.number()
}).partitionKey('email');

// The type is automatically inferred:
type User = typeof users.infer;
// {
//   id: string;
//   email: string;
//   name?: string;
//   age: number;
// }

// Query results are typed automatically
const user = await db.users.findUnique({
  where: { id: '123', email: 'john@example.com' }
});
// Type: User | null (no manual type annotation needed!)

// Select creates a new type
const partial = await db.users.findUnique({
  where: { id: '123', email: 'john@example.com' },
  select: { name: true, age: true }
});
// Type: { name?: string; age: number } | null

Custom Type Errors

When you make a mistake, CosmosQL provides helpful error messages:

// Missing partition key
const user = await db.users.findUnique({
  where: { id: '123' }
});

// Error message in IDE:
// ❌ PARTITION KEY REQUIRED
//
// This operation requires a partition key to avoid expensive cross-partition queries.
//
// 💡 Fix: Add the partition key to your where clause:
//    where: { id: '123', email: 'john@example.com' }
//
// 📖 Learn more about partition keys: https://cosmosql.dev/docs/partition-keys

Type Guards

Runtime type checking when needed:

import { isCosmosError } from 'cosmosql';

try {
  await db.users.create({ data: user });
} catch (error) {
  if (isCosmosError(error)) {
    // error is now typed as CosmosError
    console.log(error.code, error.statusCode, error.retryAfter);
  }
}

Write Methods

Complete reference for creating, updating, and deleting documents

View on separate page →

Write Operations

create(options)

Creates a single document.

const user = await db.users.create({
  data: {
    id: 'user_123',
    email: 'john@example.com',
    name: 'John Doe',
    age: 30,
    createdAt: new Date()
    // isActive will use default value if defined
  }
});
// Returns: Full user object (fully typed)

createMany(options)

Creates multiple documents in a single operation. All documents must share the same partition key.

await db.users.createMany({
  data: [
    { id: 'user_1', email: 'user1@test.com', name: 'User 1', age: 25 },
    { id: 'user_2', email: 'user2@test.com', name: 'User 2', age: 30 }
  ],
  partitionKey: 'shared@email.com' // All must share partition key
});

update(options)

Updates a single document by ID and partition key.

await db.users.update({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  },
  data: {
    age: 31,
    name: 'John Smith'
  }
});
// Returns: Updated user object

updateMany(options)

Updates multiple documents matching criteria. Can be used within a partition or across partitions.

// Update within a partition
await db.users.updateMany({
  partitionKey: 'tenant-1',
  where: { isActive: false },
  data: { status: 'archived' }
});

// Update across partitions (expensive)
await db.users.updateMany({
  enableCrossPartitionQuery: true,
  where: { lastLogin: { lt: new Date('2024-01-01') } },
  data: { isActive: false }
});

upsert(options)

Updates a document if it exists, creates it if it doesn't.

await db.users.upsert({
  where: {
    id: 'user_123',
    email: 'john@example.com'
  },
  create: {
    id: 'user_123',
    email: 'john@example.com',
    name: 'New User',
    age: 25
  },
  update: {
    age: 26,
    name: 'Updated Name'
  }
});

delete(options)

Deletes a single document by ID and partition key.

await db.users.delete({
  where: {
    id: 'user_123',
    email: 'john@example.com' // Partition key required
  }
});

deleteMany(options)

Deletes multiple documents matching criteria. Can be used within a partition or across partitions.

// Delete within a partition
const deletedCount = await db.users.deleteMany({
  partitionKey: 'tenant-1',
  where: {
    isActive: false,
    createdAt: { lt: new Date('2023-01-01') }
  }
});

// Delete across partitions (expensive)
await db.users.deleteMany({
  enableCrossPartitionQuery: true,
  where: { status: 'deleted' }
});

Write Options

where - Document identification

For single-document operations (update, delete, upsert):

where: {
  id: 'user_123',
  email: 'john@example.com' // Partition key required
}

For multi-document operations (updateMany, deleteMany):

where: {
  isActive: false,
  createdAt: { lt: new Date('2023-01-01') }
}

data - Update data

data: {
  // Direct field updates
  name: 'New Name',
  age: 31,
  
  // Dynamic updates (using function)
  views: (doc) => doc.views + 1,
  
  // Nested object updates
  profile: {
    name: 'John',
    avatar: 'url'
  }
}

partitionKey - Scope to partition

partitionKey: 'john@example.com'        // Single value
partitionKey: ['tenant_1', 'org_a']      // Composite partition key (array)

enableCrossPartitionQuery - Allow cross-partition operations

enableCrossPartitionQuery: true // Must explicitly opt-in for multi-document operations

See Creating Documents, Updating Documents, and Deleting Documents for detailed guides.