Skip to content

Creating Adapters

Experimental

DB Adapters are experimental. APIs may change at any moment.

Database adapters implement the bridge between AtscriptDbTable and a specific database engine. The BaseDbAdapter abstract class from @atscript/utils-db defines the contract that every adapter must fulfill.

Architecture

AtscriptDbTable ──delegates CRUD──▶ BaseDbAdapter (abstract)
               ◀──reads metadata──  (via this._table)

When you create an AtscriptDbTable, it registers itself with the adapter via registerTable(). The adapter can then access all table metadata (field descriptors, indexes, column mappings, etc.) through this._table.

Implementing an Adapter

To create a new database adapter, extend BaseDbAdapter and implement the abstract methods:

typescript
import { BaseDbAdapter } from '@atscript/utils-db'
import type {
  TDbInsertResult,
  TDbInsertManyResult,
  TDbUpdateResult,
  TDbDeleteResult,
} from '@atscript/utils-db'
import type { FilterExpr, Uniquery } from '@uniqu/core'

class MyAdapter extends BaseDbAdapter {
  // --- Insert ---
  async insertOne(data: Record<string, unknown>): Promise<TDbInsertResult> {
    // data is already validated, defaults applied, columns mapped
    // Implement database-specific insert logic
  }

  async insertMany(data: Array<Record<string, unknown>>): Promise<TDbInsertManyResult> {
    // Bulk insert
  }

  // --- Read ---
  async findOne(query: Uniquery): Promise<Record<string, unknown> | null> {
    // Use query.filter for WHERE, query.controls for ORDER/LIMIT/SELECT
  }

  async findMany(query: Uniquery): Promise<Array<Record<string, unknown>>> {
    // Use query.filter for WHERE, query.controls for ORDER/LIMIT/SELECT
  }

  async count(query: Uniquery): Promise<number> {
    // Use query.filter for WHERE
  }

  // --- Update ---
  async updateOne(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult> {
    // Update one matching row
  }

  async updateMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult> {
    // Update all matching rows
  }

  async replaceOne(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult> {
    // Full replacement of one matching row
  }

  async replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult> {
    // Full replacement of all matching rows
  }

  // --- Delete ---
  async deleteOne(filter: FilterExpr): Promise<TDbDeleteResult> {
    // Delete one matching row
  }

  async deleteMany(filter: FilterExpr): Promise<TDbDeleteResult> {
    // Delete all matching rows
  }

  // --- Schema ---
  async syncIndexes(): Promise<void> {
    // Create/drop indexes to match annotations
  }

  async ensureTable(): Promise<void> {
    // Create table if not exists
  }
}

Method Purpose Reference

MethodWhen CalledWhat to Do
insertOneAfter validation, defaults, column mappingExecute INSERT
insertManyAfter per-item validation + defaultsExecute batch INSERT
findOneQuery with filter + controlsExecute SELECT ... LIMIT 1
findManyQuery with filter + controlsExecute SELECT with sort/limit/skip/select
countCount queryExecute COUNT with filter
updateOneAfter validation, with filter for PKExecute UPDATE ... LIMIT 1
updateManyBulk update by filterExecute UPDATE matching filter
replaceOneFull replacement by PK filterExecute REPLACE or DELETE+INSERT
replaceManyBulk replace by filterExecute bulk REPLACE
deleteOneDelete by PK filterExecute DELETE ... LIMIT 1
deleteManyBulk delete by filterExecute DELETE matching filter
ensureTableExplicit call by userCreate table/collection DDL
syncIndexesExplicit call by userDiff + create/drop indexes

TIP

Data passed to insert/update methods is already processed by AtscriptDbTable — defaults applied, @db.ignore fields stripped, columns mapped. The adapter only needs to translate to its database's query language.

Adapter Hooks

Adapters can optionally implement hooks that are called during metadata scanning:

typescript
class MyAdapter extends BaseDbAdapter {
  // Called before the table starts scanning fields
  onBeforeFlatten(type: TAtscriptAnnotatedType): void {
    // Pre-process the root type (e.g., inject synthetic fields)
  }

  // Called for each field during scanning
  onFieldScanned(field: string, type: TAtscriptAnnotatedType, metadata: TMetadataMap): void {
    // Process adapter-specific annotations on each field
  }

  // Called after all fields are scanned
  onAfterFlatten(): void {
    // Post-process metadata (e.g., set adapter-specific primary keys)
  }

  // Override the table name derived from @db.table
  getAdapterTableName(type: TAtscriptAnnotatedType): string | undefined {
    // Return a custom table name or undefined to use the default
  }
}

Hook Use Cases

HookPurposeExample
onBeforeFlattenPre-process the type before scanningMongoDB: read @db.mongo.search.dynamic from type metadata
onFieldScannedProcess adapter-specific annotations per fieldMongoDB: detect @db.default.fn 'increment', register search fields
onAfterFlattenPost-process after all fields are knownMongoDB: hardcode _id as primary key, associate vector filters
getAdapterTableNameOverride table name resolutionReturn undefined to use the generic @db.table name

ID Preparation

Adapters can transform primary key values before they're used in queries:

typescript
class MongoAdapter extends BaseDbAdapter {
  prepareId(id: unknown, fieldType: TAtscriptAnnotatedType): unknown {
    // Convert string IDs to ObjectId, parse UUIDs, etc.
    return new ObjectId(id as string)
  }
}

This is called by findById() when converting user-provided ID strings into the database's native format.

Native Patch Support

If your database supports native array patch operations (like MongoDB's $push, $pull), implement these methods:

typescript
class MongoAdapter extends BaseDbAdapter {
  supportsNativePatch(): boolean {
    return true
  }

  async nativePatch(filter: FilterExpr, patch: unknown): Promise<TDbUpdateResult> {
    // Convert patch operators to database-native operations
  }
}

When supportsNativePatch() returns false (the default), AtscriptDbTable uses decomposePatch() to flatten patch operations into standard update calls.

Nested Object Support

If your database handles nested objects natively (like MongoDB with embedded documents), override supportsNestedObjects():

typescript
class MongoAdapter extends BaseDbAdapter {
  supportsNestedObjects(): boolean {
    return true
  }
}

When supportsNestedObjects() returns true:

  • Nested objects are passed through as-is (no flattening into __-separated columns)
  • @db.json is ignored (the adapter handles all storage decisions)
  • Read results are returned as-is (no reconstruction)
  • Index field names use dot-notation paths directly

When it returns false (the default), the generic AtscriptDbTable layer handles all flattening, reconstruction, and query translation. Adapters receive pre-flattened data with physical column names — they never need to know about logical dot-notation paths.

Validator Plugins

Adapters can inject custom validation plugins:

typescript
class MongoAdapter extends BaseDbAdapter {
  override getValidatorPlugins(): TValidatorPlugin[] {
    return [validateMongoIdPlugin]
  }

  override buildInsertValidator(table: AtscriptDbTable): Validator {
    // Custom insert validator — e.g., make ObjectId PKs optional
    return table.createValidator({
      plugins: this.getValidatorPlugins(),
      replace: (type, path) => {
        if (path === '_id') return { ...type, optional: true }
        return type
      },
    })
  }
}

Index Sync Helper

BaseDbAdapter provides a syncIndexesWithDiff() helper for implementing syncIndexes():

typescript
class MyAdapter extends BaseDbAdapter {
  async syncIndexes(): Promise<void> {
    await this.syncIndexesWithDiff({
      async listExisting() {
        // Return existing indexes from the database
        return [{ name: 'atscript__plain__email' }]
      },
      async createIndex(index) {
        // Create a single index in the database
      },
      async dropIndex(name) {
        // Drop a single index from the database
      },
      prefix: 'atscript__',  // Only manage indexes with this prefix
      shouldSkipType(type) {
        // Skip unsupported index types (e.g., fulltext for SQLite)
        return type === 'fulltext'
      },
    })
  }
}

This helper computes the diff between declared indexes and existing indexes, then creates missing ones and drops stale ones.

Accessing Table Metadata

Inside your adapter, this._table gives access to all metadata:

typescript
class MyAdapter extends BaseDbAdapter {
  async ensureTable(): Promise<void> {
    const tableName = this._table.tableName
    const schema = this._table.schema
    const fields = this._table.fieldDescriptors
    const primaryKeys = this._table.primaryKeys

    // Build CREATE TABLE from field descriptors
    for (const field of fields) {
      // field.path, field.physicalName, field.designType,
      // field.optional, field.isPrimaryKey, field.ignored,
      // field.defaultValue, field.storage, field.flattenedFrom
    }
  }
}

Accessing the Adapter

Use table.getAdapter() to access the underlying adapter for database-specific operations that go beyond the generic CRUD interface:

typescript
const adapter = table.getAdapter() as MongoAdapter

// Access the raw MongoDB collection
const collection = adapter.collection
await collection.bulkWrite([...])

// Run an aggregation pipeline
const cursor = adapter.collection.aggregate([
  { $match: { completed: true } },
  { $group: { _id: null, count: { $sum: 1 } } },
])

This is the recommended way to perform native operations — the adapter exposes all database-specific methods and properties directly.

Available Adapters

AdapterPackageDatabase
SqliteAdapter@atscript/db-sqliteSQLite (via better-sqlite3 or node:sqlite)
MongoAdapter@atscript/mongoMongoDB

Released under the ISC License.