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
init.table— Table or collection name. It must match/^[a-zA-Z_][a-zA-Z0-9_]*$/.init.db— A Primate database adapter instance.init.schema— Pema fields plus optional key and relation descriptors.init.migrate— Whether generated migrations include this store. Default:true.init.id— Store identity symbol. Default: a new symbol.init.extend— Members copied onto the instance. PreferStore.extend()in application code.
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
type— A Pemastring, UUID, or unsigned integer storable type.options.generate— Ask the adapter to generate values. Default:true.
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>;options.tablenames the related store's table.options.bynames the foreign-key field used to join it.options.reversereverses the join direction. Default:false.
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>;options.wheredefaults to{}, which counts all records.- Unknown fields, invalid values, and unsupported operators throw before the adapter query runs.
- Relation loading is not supported.
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
>>;pkvis validated as the schema's primary-key type.selectreturns only the listed base fields and must be non-empty with no duplicates.withloads declared relations and checks that each supplied store has the declared table name.
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
>[]>;- Omitted
wherematches every record. selectprojects base fields and changes the inferred result type.sortmaps fields to"asc"or"desc"and must not be empty.limitandoffsetmust be unsigned integers;offsetrequireslimit.withmaps relation names to a related store or aWithQuery.
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>[]>;undefinedfields are treated as omitted; explicitnullis rejected.- The Pema schema supplies defaults and the adapter supplies generated keys.
- Batch insertion uses
db.createMany()and preserves adapter result order. - An empty array returns
[]without calling the adapter. - Sequence fields require an adapter that implements sequence allocation.
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>;- The primary-key overload validates the key and requires the adapter to report exactly one updated record; zero or multiple records throw.
- The query overload returns the adapter's updated-record count. Omitted or
empty
whereupdates all records. setmust contain at least one defined field. The primary key is immutable.- Unknown fields, invalid values, and
nullfor non-nullable fields throw.
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>;- The primary-key overload requires the adapter to report exactly one deleted record; zero or multiple records throw.
- The query overload returns the deleted-record count and rejects a missing or
empty
whereobject, preventing an accidental unfiltered delete.
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:
- PostgreSQL uses
FOR NO KEY UPDATE. Competing row writers wait, while a foreign-key insert may take a compatible key-share lock. - MySQL uses
SELECT ... FOR UPDATE; held row locks require a transactional storage engine such as InnoDB. - Oracle uses
SELECT ... FOR UPDATE. Oracle DDL may commit implicitly and is not part of the scoped store contract. - SQLite begins with
BEGIN IMMEDIATE. Its fence is database-wide;lock()reads and returns the row under that existing fence rather than adding a row-level lock.
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>;