Stores
A store is a collection of methods for accessing data. Stores can be backed by APIs, filesystems, caches, or other sources.
The most common store is the DatabaseStore
, a collection of records with
a schema and a common interface, backed by a relational or document database.
This page focuses on database stores, the most widely used.
Mapping
Driver | Maps to | Examples |
---|---|---|
SQL | table | SQLite, MySQL, PostgreSQL |
Document/NoSQL | collection | MongoDB, SurrealDB |
Stores have:
- Schema — field names and types (Pema)
- API — type-safe CRUD and queries
- Driver — in-memory by default; override in config
Define a store
Create a file under stores
and export a store.
import date from "pema/date";
import primary from "pema/primary";
import string from "pema/string";
import store from "primate/store";
export default store({
id: primary,
title: string.max(100),
body: string,
created: date.default(() => new Date()),
});
Database stores require an id: primary
field. Pema types (string
, number
,
boolean
, date
, etc.) enforce validation and map to database columns.
stores/User.ts
->user
stores/login/User.ts
->login_user
Override with a custom name.
Configure a database
If you don't configure a database, the default one is in‑memory, great for prototyping and tests. To add a database, install a Primate driver.
$ npm install @primate/sqlite
$ npm install @primate/postgresql
$ npm install @primate/mysql
$ npm install @primate/mongodb
$ npm install @primate/surrealdb
Create a database file
Create config/database
and place your database file there.
import sqlite from "@primate/sqlite";
export default sqlite({ database: "/tmp/primate.db" });
import postgresql from "@primate/postgresql";
export default postgresql({ database: "primate" });
import mysql from "@primate/mysql";
export default mysql({ database: "primate" });
import mongodb from "@primate/mongodb";
export default mongodb({ database: "primate" });
import surrealdb from "@primate/surrealdb";
export default surrealdb({ database: "primate" });
index.ts
or default.ts
.Lifecycle
Create tables or collections at app startup, e.g. in a route file.
import Post from "#store/Post";
await Post.schema.create();
// ... later (e.g., tests/teardown)
// await Post.schema.delete();
You can safely call create()
multiple times; drivers treat it as idempotent.
Usage in routes
A typical route that reads (or creates) a record, then renders a view:
import Post from "#store/Post";
import pema from "pema";
import string from "pema/string";
import response from "primate/response";
import route from "primate/route";
route.get(async () => {
// fetch the most recent posts
const posts = await Post.find({}, {
sort: { created: "desc" },
select: { id: true, title: true, created: true },
limit: 20,
});
return response.view("posts.jsx", { posts });
});
route.post(async request => {
const body = request.body.fields(pema({
title: string.max(100),
body: string,
}).coerce);
const created = await Post.insert(body);
return view("posts/created.jsx", { post: created });
});
post
route handler, two types of validations take place: the shape of
the body
is validated against an ad-hoc Pema schema — it must contain a title
(≤100 chars) and a body. Later, before insertion, the entire record to be
inserted is validated. This distinction is important: not all backend
validation needs to repeat at the store layer.Extending stores
Add custom methods to stores with .extend()
. Extensions can be defined
inline or in separate files for modularity.
// stores/User.ts
import primary from "pema/primary";
import string from "pema/string";
import u8 from "pema/u8";
import store from "primate/store";
export default store({
id: primary,
name: string,
age: u8.range(0, 120),
lastname: string.optional(),
}).extend(User => ({
findByAge(age: typeof User.R.age) {
return User.find({ age });
},
async getAverageAge() {
const users = await User.find({});
return users.reduce((sum, u) => sum + u.age, 0) / users.length;
},
}));
Modular extensions
Create a base store:
// stores/User.ts
import primary from "pema/primary";
import string from "pema/string";
import u8 from "pema/u8";
import store from "primate/store";
export default store({
id: primary,
name: string,
age: u8.range(0, 120),
lastname: string.optional(),
});
Create an extended version:
import Base from "#store/User";
export default Base.extend(User => {
type R = typeof User.R;
return {
findByAge(age: R["age"]) {
return User.find({ age });
},
findByNamePrefix(prefix: R["name"]) {
return User.find({ name: { $like: `${prefix}%` } });
},
async updateAge(id: R["id"], age: R["age"]) {
return User.update(id, { age });
},
async getAverageAge() {
const users = await User.find({});
return users.reduce((sum, u) => sum + u.age, 0) / users.length;
},
};
});
Use in routes:
import User from "#store/UserExtended";
import route from "primate/route";
route.get(async () => {
const bobs = await User.findByNamePrefix("Bob");
const byAge = await User.findByAge(25);
const average = await User.getAverageAge();
return { bobs, byAge, average };
});
Extension types
Access field types with typeof <param>.R.<fieldName>
. If you extract this
into a type, you'll use R[fieldName]
.
User => {
type R = typeof User.R;
return {
// id: string (primary key)
findById(id: R["id"]) {
return User.get(id);
},
// name: string
updateName(id: R["id"], name: R["name"]) {
return User.update(id, { name });
},
};
}
.extend(param => { ... })
is up
to you. We use User
for clarity, but This
(or any name) works the same.
Access types via typeof <param>.R.<field>
.find
, insert
, update
,
etc.) and can combine them to create higher-level operations.API
method | returns | description |
---|---|---|
insert(record) | T |
insert a record |
get(id) | T |
fetch a record or throw |
try(id) | T | undefined |
return a record or undefined |
has(id) | boolean |
check if a record exists |
find(criteria, options?) | T[] |
find records by criteria |
count(criteria) | number |
count records by criteria |
update(id, changes) | void |
update a single record |
update(criteria, changes) | number |
update multiple records |
delete(id) | void |
delete a single record |
delete(criteria) | number |
delete multiple records |
insert(record)
Insert a record and return it. id
is optional on input and is generated if
not supplied — the output record is guaranteed to contain an id.
The insert
operation validates the input before passing it to the driver and,
if it fails, throws a Pema ParseError
.
const post = await Post.insert({ title: "Hello", body: "..." });
post.id; // string
get(id)
Fetch the record associated with the given id
. Throws if the record doesn't
exist in the database.
const post = await Post.get(id);
try(id)
Like get(id)
, but instead of throwing if no record is found, it returns
undefined
.
const post = await Post.try(id);
if (post === undefined) {
// not found
}
has(id)
Check existence by id.
if (await Post.has(id)) { /* ... */ }
find(criteria, options?)
Query by field criteria with optional projection, sort, and limit.
// all posts by title
await Post.find({ title: "Hello" });
// projection: only return certain fields
await Post.find({}, { select: { id: true, title: true } });
// sorting and limiting
await Post.find({}, {
sort: { created: "desc", title: "asc" },
limit: 10,
});
count(criteria)
Count matching records.
await Post.count({ title: "Hello" });
update(id, changes)
Update a single record by id. Throws if the record is not found.
await Post.update(id, { title: "Updated" });
update(criteria, changes)
Update all records matching criteria. Returns the number of records updated (may be 0).
// multiple records -> returns count
const n = await Post.update({ title: "Draft" }, { title: "Published" });
Unsetting fields
If a field is optional in your schema, passing null
in changes
unsets it (removes it).
// given: body?: string (optional)
await Post.update(id, { body: null });
const fresh = await Post.get(id);
// fresh.body is now undefined
delete(id)
Delete a single record by id. Throws if the record is not found.
// throws if not found
await Post.delete(id);
delete(criteria)
Delete all records matching criteria. Returns the number of records deleted (may be 0).
// returns count of deleted records
await Post.delete({ title: "..." });
Criteria
Criteria are equality checks:
await Post.find({ title: "Hello" });
$like
(e.g.,
{ name: { $like: "Jo%" } }
). Drivers translate this appropriately
(SQL -> LIKE
, MongoDB -> $regex
, SurrealDB -> string::matches()
).Types for stores
Any Pema type can be a field. Common ones:
primary
— primary keystring
,number
,boolean
,date
optional(T)
— nullable; can be unset withnull
Example:
import optional from "pema/optional";
import i32 from "pema/i32";
export default store({
id: primary,
subtitle: optional(string.max(120)),
likes: i32.range(0, 1_000_000),
});
Custom database
By default, stores use the app's default database. If you have multiple databases in your app, you can pin a store to a specific one.
import store from "primate/store";
// config/database/postgresql.ts
import postgresql from "#database/postgresql";
import primary from "pema/primary";
import date from "pema/date";
import string from "pema/string";
export default store(
{
id: primary,
message: string,
created: date.default(() => new Date()),
},
{
// pin to a specific database
database: postgresql,
},
);
If you omit database
, the default database is used.
Custom name
Override the default table or collection name with name
. Useful for exposing
part of a table as a store.
import store from "primate/store";
import primary from "pema/primary";
import date from "pema/date";
import string from "pema/string";
export default store(
{
id: primary,
message: string,
created: date.default(() => new Date()),
},
{
name: "audit_log",
},
);
If you omit name
, the name will be generated from the filename.