Store API

primate/store exports the factory for Primate's typed, database-backed stores.

import store from "primate/store";

The declarations on this page use the same generic names as the implementation. Store, StoreInput, and the other supporting aliases describe the inferred public type; they are not additional named exports from primate/store. See Types for their definitions and the Stores guide for schema design, migrations, and longer examples.

store()

Creates a Store from a database, table name, and Pema schema. TypeScript infers both the schema T and literal table name N.

function store<
  T extends StoreInput,
  const N extends string = string,
>(init: Omit<Init<T, N>, "schema"> & { schema: T }): Store<T, N>;

Parameters

Returns: Store<T, N>.

Throws: If required values are absent, table is not a valid identifier, or an extension member would replace an existing member.

// stores/Post.ts
import db from "@/config/db";
import p from "pema";
import store from "primate/store";

export default store({
  table: "post",
  db,
  schema: {
    id: store.key.primary(p.u32),
    title: p.string.max(100),
    published: p.boolean.default(false),
  },
});

store.key.primary()

Marks one schema field as the store's primary key.

function primary<T extends AllowedPKType>(
  type: T,
  options?: { generate?: boolean },
): PrimaryKey<T>;

Parameters

Returns: A PrimaryKey<T> descriptor exposing type, datatype, name, nullable (false), generate, and parse(value).

Throws: For an unsupported type, or when generation is enabled for a string key. String keys therefore require { generate: false }.

store.key.foreign()

Marks a schema field as a foreign key. The descriptor may wrap an optional key type when the relationship is nullable.

function foreign<T extends AllowedFKType>(type: T): ForeignKey<T>;

Returns: A ForeignKey<T> descriptor exposing type, datatype, name, nullable, and parse(value) from the wrapped Pema type.

store.relation.one()

Declares a to-one relation. Relations describe loading; they are not stored as base record fields.

function one<const N extends string, const FK extends string>(
  options: { table: N; by: FK; reverse?: boolean },
): OneRelation<N, FK>;

The relation is loaded only when passed through a read method's with option.

store.relation.many()

Declares a to-many relation.

function many<const N extends string, const FK extends string>(
  options: { table: N; by: FK },
): ManyRelation<N, FK>;

table names the related table and by names its joining foreign-key field. Loaded values are arrays.

store.relation.is()

Checks whether a value is a relation descriptor.

function is(value: unknown): value is Relation;

The check recognizes object values whose type is "one" or "many".

Store type

The factory returns this inferred public class type:

declare class Store<
  T extends StoreInput,
  N extends string = string,
> {
  declare readonly Insert: Unpack<Insertable<T>>;
  declare readonly Schema: Schema<T>;

  create(): Promise<void>;
  drop(): Promise<void>;
  count(options?: { where?: Where<T> }): Promise<number>;
  has(pkv: PKV<T>): Promise<boolean>;
  lock(/* overloads below */): Promise<unknown>;
  get(/* overloads below */): Promise<unknown>;
  try(/* overloads below */): Promise<unknown>;
  insert(/* overloads below */): Promise<unknown>;
  update(/* overloads below */): Promise<void | number>;
  delete(/* overloads below */): Promise<void | number>;
  find(/* generic signature below */): Promise<unknown[]>;
  first(/* generic signature below */): Promise<unknown>;
  toJSON(): { type: "object"; properties: Dict<Serialized> };
  extend<A extends Dict>(extensor: (This: this) => A): this & A;
}

The abbreviated entries above are an inventory; each complete declaration is shown under its member heading.

Store.Insert type property

A type-only property for the input accepted by Store.insert(). Generated primary keys, defaulted fields, and sequence fields are optional; other record fields are required.

declare readonly Insert: Unpack<Insertable<T>>;
type NewPost = typeof Post.Insert;

This declared property does not hold a runtime value.

Store.Schema type property

A type-only property for a complete parsed base record. Relation descriptors are excluded.

declare readonly Schema: Schema<T>;
type PostRecord = typeof Post.Schema;

This declared property does not hold a runtime value.

Store.infer

Another type inference helper for the complete base record.

get infer(): InferRecord<T>;
type PostRecord = typeof Post.infer;

The runtime value is undefined; use this property only with typeof.

Store.schema

The parsed Pema store schema used to validate records.

get schema(): StoreType<ExtractSchema<T>>;

It exposes the normal Pema object-schema operations, including parse(), partial(), properties, and toJSON().

Store.table

The literal table or collection name supplied to store().

get table(): N;

Store.db

The database adapter supplied to store().

get db(): DB;

Store.pk

The parsed primary-key field name, or null when the schema has none.

get pk(): string | null;

Primary-key operations throw when no primary key is defined.

Store.types

Maps each stored field to its Pema database datatype.

get types(): { [field: string]: keyof DataType };

Relation names are not included.

Store.migrate

Whether generated migrations include this store.

get migrate(): boolean;

The value defaults to true.

Store.id

The identity shared by a store and instances derived with Store.extend().

get id(): symbol;

Store.create()

Creates the backing table or collection through db.schema.create().

create(): Promise<void>;

Primate migrations normally manage this lifecycle; direct calls are mainly useful in controlled tooling.

Store.drop()

Deletes the backing table or collection through db.schema.delete().

drop(): Promise<void>;

Store.count()

Counts records matching an optional condition.

count(options?: { where?: Where<T> }): Promise<number>;

Store.has()

Checks whether exactly one record has a primary-key value.

has(pkv: PKV<T>): Promise<boolean>;

The key is validated against the schema. It throws if the store has no primary key; otherwise it returns true only when the count is one.

Store.get()

Reads one record by primary key.

get(pkv: PKV<T>): Promise<Schema<T>>;

get<
  const S extends Select<Schema<T>> | undefined = undefined,
  const W extends WithInput<ExtractRelations<T>> | undefined = undefined,
>(
  pkv: PKV<T>,
  options: { select?: S; with?: W },
): Promise<WithRelations<
  Projected<Schema<T>, S>,
  ExtractRelations<T>,
  W
>>;

Throws: If there is no primary key, the key is invalid, the record is missing, more than one record is returned, or an option is invalid.

Store.try()

Performs the same read as Store.get(), but maps only its record-not-found error to undefined.

try(pkv: PKV<T>): Promise<Schema<T> | undefined>;

try<
  const S extends Select<Schema<T>> | undefined = undefined,
  const W extends WithInput<ExtractRelations<T>> | undefined = undefined,
>(
  pkv: PKV<T>,
  options: { select?: S; with?: W },
): Promise<WithRelations<
  Projected<Schema<T>, S>,
  ExtractRelations<T>,
  W
> | undefined>;

Validation, adapter, and relation errors still reject the promise.

Store.lock()

Returns one primary-key record and locks it for the active SQL transaction.

lock(pkv: PKV<T>): Promise<Schema<T>>;

lock<
  const S extends Select<Schema<T>> | undefined = undefined,
  const W extends WithInput<ExtractRelations<T>> | undefined = undefined,
>(
  pkv: PKV<T>,
  options: { select?: S; with?: W },
): Promise<WithRelations<
  Projected<Schema<T>, S>,
  ExtractRelations<T>,
  W
>>;

The return type, select and with options, decoding, and missing-record behavior match Store.get(). The base record is returned by the same backend query that acquires its lock; this is not an existence check followed by a separate read. Relations requested with with are returned but are not implicitly row-locked. SQLite is the exception at the storage level: its existing transaction fence excludes writes across the database.

The key is validated against the store schema. The promise resolves with the record when the backend's lock or write fence is held and rejects if the row does not exist. Calling lock() without a live transaction rejects with transaction_required; calling it after commit but before Transaction.end() rejects with transaction_stale. See SQL transactions for backend differences.

Store.find()

Returns all records matching a query.

find<
  const S extends Select<Schema<T>> | undefined = undefined,
  const W extends WithInput<ExtractRelations<T>> | undefined = undefined,
>(options?: {
  where?: Where<T>;
  select?: S;
  sort?: Sort<Schema<T>>;
  limit?: number;
  offset?: number;
  with?: W;
}): Promise<WithRelations<
  Projected<Schema<T>, S>,
  ExtractRelations<T>,
  W
>[]>;

Unknown options, fields, relation names, and operators throw.

const posts = await Post.find({
  where: { published: true },
  select: ["id", "title"],
  sort: { id: "desc" },
  limit: 20,
});

Store.first()

Returns the first matching record, or undefined.

first<
  const S extends Select<Schema<T>> | undefined = undefined,
  const W extends WithInput<ExtractRelations<T>> | undefined = undefined,
>(options?: {
  where?: Where<T>;
  select?: S;
  sort?: Sort<Schema<T>>;
  offset?: number;
  with?: W;
  limit?: never;
}): Promise<WithRelations<
  Projected<Schema<T>, S>,
  ExtractRelations<T>,
  W
> | undefined>;

It accepts the corresponding Store.find() options except limit; passing limit throws because this method always reads one record.

Store.insert()

Validates and inserts one record or an array of records.

insert(record: Insertable<T>): Promise<Schema<T>>;
insert(records: readonly Insertable<T>[]): Promise<Schema<T>[]>;

Unknown fields, invalid records, or unsupported sequences throw.

const post = await Post.insert({ title: "Hello" });
const posts = await Post.insert([{ title: "One" }, { title: "Two" }]);

Store.update()

Updates either one primary-key record or every record matching a condition.

update(pkv: PKV<T>, options: { set: $Set<T> }): Promise<void>;
update(options: { where?: Where<T>; set: $Set<T> }): Promise<number>;
await Post.update(42, { set: { published: true } });
const changed = await Post.update({
  where: { title: { $like: "Draft%" } },
  set: { published: false },
});

Store.delete()

Deletes either one primary-key record or records matching a required filter.

delete(pkv: PKV<T>): Promise<void>;
delete(options: { where: Where<T> }): Promise<number>;

Store.extend()

Creates another store instance with application-specific members.

extend<A extends Dict>(extensor: (This: this) => A): this & A;

extensor receives the current store. The new instance keeps its schema, database, table, migration setting, and identity. An extension key cannot replace an existing store member.

const PublishedPost = Post.extend(This => ({
  published() {
    return This.find({ where: { published: true } });
  },
}));

Store.toJSON()

Serializes the Pema schema.

toJSON(): { type: "object"; properties: Dict<Serialized> };

The result describes stored fields; relation descriptors are excluded.

SQL transactions

PostgreSQL, MySQL, Oracle, and SQLite support transactions on the database supplied to a store:

interface TransactionalDB extends DB {
  transaction(): Promise<Transaction>;
}

interface Transaction extends AsyncDisposable {
  commit(): Promise<void>;
  end(): Promise<void>;
  [Symbol.asyncDispose](): Promise<void>;
}
import Account from "@/stores/Account";
import Transfer from "@/stores/Transfer";
import db from "@/config/db";

await using tx = await db.transaction();
const amount = 100;
const account = await Account.lock("primary");

if (account.balance < amount) {
  throw new Error("Insufficient funds");
}

await Transfer.insert({ accountId: account.id, amount });
await Account.update(account.id, {
  set: { balance: account.balance - amount },
});
await tx.commit();

Only db.transaction() starts a transaction. Primate does not automatically start one for a request or store operation. Every store using that exact database instance in the current asynchronous context routes its reads, relations, writes, batches, generated keys, and sequences through the pinned transaction executor. Schema creation, deletion, introspection, and alteration are outside this contract.

commit() persists the work but deliberately leaves the ambient scope in a stale state. end() clears it. The await using declaration invokes end() automatically: uncommitted work is rolled back and that rollback is awaited; committed work is never rolled back. end() is idempotent, so calling it explicitly before async disposal is safe.

Nested transactions for the same database instance reject with transaction_nested, including sibling work inherited through the same asynchronous parent. Independently rooted requests and different database instances may transact concurrently. Work that outlives its disposed scope rejects with transaction_stale rather than silently running outside the transaction.

Locking differs by backend:

MemoryDB, JSONDB, and MongoDB reject transaction() with transaction_unsupported. MongoDB may still use an internal transaction to make a batch insert atomic; that does not expose a public scoped transaction.

Types

These aliases explain the exact declarations above. They are documentation for the inferred primate/store surface, not named exports from that module.

StoreInput

type StoreField =
  | Storable<DataKey>
  | PrimaryKey<AllowedPKType>
  | ForeignKey<AllowedFKType>
  | Relation;

type StoreInput = Dict<StoreField>;

type Init<S extends StoreInput, N extends string> = {
  table: N;
  db: DB;
  id?: symbol;
  schema: S;
  migrate?: boolean;
  extend?: Dict;
};

Schema

type InferRecord<T extends StoreInput> = InferStore<ExtractSchema<T>>;
type Schema<T extends StoreInput> = {
  [K in keyof InferRecord<T>]: InferRecord<T>[K];
};

ExtractSchema<T> removes relations and unwraps primary and foreign key descriptors before Pema infers each field's runtime value.

Insertable

type Insertable<T extends StoreInput> =
  Omit<InferRecord<T>,
    PrimaryKeyField<T> | DefaultFields<T> | SequenceFields<T>>
  & Partial<Pick<InferRecord<T>,
    PrimaryKeyField<T> | DefaultFields<T> | SequenceFields<T>>>;

PKV

type PKV<T extends StoreInput> =
  T[PrimaryKeyField<T>] extends PrimaryKey<infer P>
    ? P extends Storable<infer D>
      ? D extends "uuid" | "uuid_v4" | "uuid_v7" ? string
        : D extends NumberKey ? number
        : D extends BigIntKey ? bigint
        : string | number | bigint
      : string | number | bigint
    : string | number | bigint;

NumberKey is u8 | u16 | u32 | i8 | i16 | i32 | f32 | f64; BigIntKey is u64 | u128 | i64 | i128.

Where

type Where<T extends StoreInput> = {
  [K in keyof Schema<T>]?: QueryOperators<Schema<T>[K]>;
};

type QueryOperators<T> =
  T extends string ? T | StringOperators | null
  : T extends number ? T | NumberOperators | null
  : T extends bigint ? T | BigIntOperators | null
  : T extends Date ? T | DateOperators | null
  : T | null;

type StringOperators = {
  $like?: string;
  $ilike?: string;
  $in?: string[];
};

type NumberOperators = {
  $gt?: number;
  $gte?: number;
  $lt?: number;
  $lte?: number;
  $ne?: number;
  $in?: number[];
};

type BigIntOperators = {
  $gt?: bigint;
  $gte?: bigint;
  $lt?: bigint;
  $lte?: bigint;
  $ne?: bigint;
  $in?: bigint[];
};

type DateOperators = {
  $before?: Date;
  $after?: Date;
  $ne?: Date;
  $in?: Date[];
};

At runtime, string operators apply to string and time fields; numeric operators apply to matching number or bigint fields; date operators apply to datetime fields; and UUID fields support $in. URL, boolean, and blob fields accept direct values or null, not operator objects. Operator objects and $in arrays must not be empty. Numeric values must be finite and within the Pema datatype's range.

$Set

type $Set<T extends StoreInput> = {
  [P in keyof InferStore<ExtractSchema<T>>]?:
    null | InferStore<ExtractSchema<T>>[P] | undefined;
};

Runtime validation permits null only for nullable schema fields.

Select

type SelectKey<T> = Extract<keyof T, string>;
type Select<T> = readonly SelectKey<T>[];
type Sort<T> = Partial<Record<SelectKey<T>, "asc" | "desc">>;

type Projected<T, S extends Select<T> | undefined> =
  S extends readonly (keyof T)[] ? Pick<T, S[number]> : T;

WithInput

type WithQuery<N extends string, S extends Store<any, N>> = {
  store: S;
  where?: Where<StoreT<S>> & DataDict;
  select?: readonly (keyof Schema<StoreT<S>> & string)[];
  sort?: Sort<Schema<StoreT<S>>> & Dict<"asc" | "desc">;
  limit?: number;
};

type WithInput<R extends Dict<Relation>> = {
  [K in keyof R]?:
    | Store<any, RelationTable<R[K]>>
    | WithQuery<RelationTable<R[K]>, Store<any, RelationTable<R[K]>>>;
};

WithQuery

A relation query can filter, project, sort, or limit the related records. It does not accept offset or nested with.

const posts = await Post.find({
  with: {
    author: {
      store: User,
      select: ["id", "name"],
    },
  },
});

To-one relations add the selected related record or null; to-many relations add an array. The related store's literal table must match the relation descriptor.

Relation

type OneRelation<N extends string, FK extends string> = {
  type: "one";
  table: N;
  fk: FK;
  reverse: boolean;
};

type ManyRelation<N extends string, FK extends string> = {
  type: "many";
  table: N;
  fk: FK;
};

type Relation =
  | OneRelation<string, string>
  | ManyRelation<string, string>;

AllowedPKType

type AllowedPKType =
  | Storable<"uuid">
  | Storable<"uuid_v4">
  | Storable<"uuid_v7">
  | Storable<"string">
  | Storable<"u8">
  | Storable<"u16">
  | Storable<"u32">
  | Storable<"u64">
  | Storable<"u128">;

type AllowedFKType = AllowedPKType | OptionalType<AllowedPKType>;