Skip to content

Adapter Spec v1 #3

@heiwen

Description

@heiwen

Summary

This document defines the v1 adapter interface for no-orm.

The adapter layer is responsible for exposing a generic persistence interface over concrete backends such as:

  • SQLite
  • Postgres
  • MySQL
  • MongoDB
  • memory

This layer is generic and has nothing to do with any single host library or resource type.

Purpose

The adapter layer exists to:

  • consume the canonical Schema
  • support minimal schema bootstrap through migrate()
  • expose a normalized persistence interface across SQL and non-SQL backends
  • hide backend-specific mechanics behind a common contract

This layer does not own:

  • request-aware policy enforcement
  • schema extension hooks
  • write/query scoping hooks
  • host-library-specific logic

Those concerns belong in higher layers.

Design Principles

  • The interface should be generic, not tied to a specific resource such as conversations.
  • The interface should be method-oriented rather than SQL-oriented.
  • The interface should be complete enough to cover standard CRUD-style persistence needs.
  • The interface should work for both SQL and non-SQL backends.
  • The interface should prefer generic operators such as eq over backend-specific operators such as json_eq.

Interface

export interface Adapter {
  migrate?(args: {
    schema: Schema;
  }): Promise<void>;

  transaction?<T>(fn: (tx: Adapter) => Promise<T>): Promise<T>;

  create<T = Record<string, unknown>>(args: {
    model: string;
    data: T;
    select?: Select<T>;
  }): Promise<T>;

  update<T = Record<string, unknown>>(args: {
    model: string;
    where: Where<T>;
    data: Partial<T>;
  }): Promise<T | null>;

  updateMany<T = Record<string, unknown>>(args: {
    model: string;
    where?: Where<T>;
    data: Partial<T>;
  }): Promise<number>;

  upsert?<T = Record<string, unknown>>(args: {
    model: string;
    where: Where<T>;
    create: T;
    update: Partial<T>;
    select?: Select<T>;
  }): Promise<T>;

  delete<T = Record<string, unknown>>(args: {
    model: string;
    where: Where<T>;
  }): Promise<void>;

  deleteMany?<T = Record<string, unknown>>(args: {
    model: string;
    where?: Where<T>;
  }): Promise<number>;

  find<T = Record<string, unknown>>(args: {
    model: string;
    where: Where<T>;
    select?: Select<T>;
  }): Promise<T | null>;

  findMany<T = Record<string, unknown>>(args: {
    model: string;
    where?: Where<T>;
    select?: Select<T>;
    sortBy?: SortBy<T>[];
    limit?: number;
    offset?: number;
    cursor?: Cursor<T>;
  }): Promise<T[]>;

  count?<T = Record<string, unknown>>(args: {
    model: string;
    where?: Where<T>;
  }): Promise<number>;
}

Supporting Types

export type FieldName<T> = Extract<keyof T, string>;

export type Select<T> = ReadonlyArray<FieldName<T>>;

export type Where<T = Record<string, unknown>> =
  | {
      field: FieldName<T>;
      op: "eq" | "ne";
      value: unknown;
    }
  | {
      field: FieldName<T>;
      op: "gt" | "gte" | "lt" | "lte";
      value: unknown;
    }
  | {
      field: FieldName<T>;
      op: "in" | "not_in";
      value: unknown[];
    }
  | {
      and: Where<T>[];
    }
  | {
      or: Where<T>[];
    };

export interface SortBy<T = Record<string, unknown>> {
  field: FieldName<T>;
  direction?: "asc" | "desc";
}

export interface Cursor<T = Record<string, unknown>> {
  after: Partial<Record<FieldName<T>, unknown>>;
}

Method Semantics

migrate

migrate?(args: { schema: Schema }): Promise<void>

Purpose:

  • prepare backend storage structures from the canonical schema

This is intentionally minimal:

  • create model if not exists
  • create indexes if needed
  • use backend-specific idempotent DDL where supported

This is not a full migration framework.

transaction

transaction?<T>(fn: (tx: Adapter) => Promise<T>): Promise<T>

Purpose:

  • allow multiple operations to run atomically where the backend supports transactions

Backends that do not support transactions may omit this method.

create

create<T>(args): Promise<T>

Purpose:

  • create a single record

update

update<T>(args): Promise<T | null>

Purpose:

  • update a single matching record

updateMany

updateMany?(args): Promise<number>

Purpose:

  • update multiple matching records
  • return the number of affected records

upsert

upsert?(args): Promise<T>

Purpose:

  • create or update a record according to backend semantics

Optional because some backends may not support native or efficient upsert in v1.

delete

delete<T>(args): Promise<void>

Purpose:

  • delete a single matching record

deleteMany

deleteMany?<T>(args): Promise<number>

Purpose:

  • delete multiple matching records
  • return the number of affected records

find

find<T>(args): Promise<T | null>

Purpose:

  • find a single record matching the filter

findMany

findMany<T>(args): Promise<T[]>

Purpose:

  • find multiple records
  • support filtering, ordering, pagination, and optional projection

count

count?<T>(args): Promise<number>

Purpose:

  • count matching records

Optional because it is not required by the current hebo-gateway benchmark, but included for completeness.

Where Semantics

The where model is intentionally small and generic.

Supported leaf operators in v1:

  • eq, ne
  • gt, gte, lt, lte
  • in, not_in

Supported logical composition in v1:

  • and
  • or

Notes:

  • There is no dedicated json_eq operator in v1.
  • Backend-specific JSON path behavior should not leak into the generic filter model unless a strong use case proves it is necessary.
  • If nested-path filtering becomes necessary later, it should likely be modeled with an optional path field rather than a separate operator family.

Ordering and Pagination

sortBy

Supports sorting by one or more fields.

cursor

The cursor shape is intentionally generic:

{
  after: Partial<Record<FieldName<T>, unknown>>;
}

This allows backends to implement cursor pagination according to their own storage model while still constraining cursor fields to keys of T.

Why This Shape

This interface is based on the combined lessons from:

  • the hebo-gateway storage-layer discussion in issue #83
  • Better Auth’s adapter interface
  • unadapter’s normalized database API approach

The key decisions are:

  • use a generic CRUD-style backend interface rather than a SQL executor interface
  • keep backend concerns in this layer
  • keep request-aware policy and schema extension outside this layer
  • keep the filter and operation surface small and portable

Out of Scope

This spec does not define:

  • schema extension hooks
  • request-aware write/query enforcement
  • host-library integration layers
  • the TypeScript inference layer
  • a full query language
  • migration history or diffing
  • backend capability metadata

Current Recommendation

Use this interface as the v1 adapter contract.

Start with:

  • one SQL adapter, likely SQLite
  • one non-SQL or non-database adapter, likely memory

That pair is enough to validate whether the interface is genuinely portable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions