Repositories
A Repository is the typed CRUD/query surface for a single model. It is the canonical persistence API in Webda: application code interacts with persistence through Repositories — never directly with Stores, which are internal infrastructure that own a backend connection and produce Repositories.
Each model class has exactly one Repository, created and registered by the Store that owns the model's backend.
Reaching a Repository
Three equivalent paths — prefer the static model methods:
// 1. Static methods on the model (preferred)
const user = await User.create({ name: "Alice" });
const result = await User.query("name = 'Alice'");
const same = await User.ref(user.getUuid()).get();
// 2. The hook — when you need the Repository object itself
import { useRepository } from "@webda/core";
const repo = useRepository(User);
await repo.create({ name: "Bob" });
// 3. Inside a Service — Repositories are NOT @Inject-able; use one of the above.
Configuring which backend stores a model is a Store concern, declared in
webda.config. App code does not name the store.
CRUD operations
// Create
const post = await Post.create({ title: "Hello", status: "draft" });
// Read
const fetched = await Post.ref(post.getUuid()).get();
// Query (WebdaQL)
const drafts = await Post.query("status = 'draft' ORDER BY title ASC LIMIT 20");
// Update / patch
await Post.ref(post.getUuid()).patch({ status: "published" });
// Delete
await Post.ref(post.getUuid()).delete();
// Iterate large result sets without paginating by hand
for await (const p of Post.iterate("status = 'published'")) {
// ...
}
Bulk statements
deleteMany and updateMany run a WebdaQL DELETE / UPDATE statement and return the number of objects
affected (query() and iterate() only take a filter):
await useRepository(Post).deleteMany("DELETE WHERE status = ? LIMIT 100", ["spam"]);
await useRepository(Post).updateMany("UPDATE SET status = 'archived' WHERE createdAt < ?", [cutoff]);
They are bulk store operations: no per-object event below fires, and no hook, validation or canAct runs.
Code that lets a client trigger them must check permissions itself. See
Statements.
Query objects
query(), iterate(), the bulk methods and the stores' find() also take a parsed Query object. An object
built or changed by code is checked and parsed again from its text (normalizeQuery in @webda/ql): it must be
expressible in WebdaQL. Custom Expression subclasses, duck-typed filters ({ eval }), Date or object values,
$-prefixed or empty path segments and non-integer LIMITs are refused with a WebdaQLError; pass a Date as its
ISO string.
Aggregation
Model.aggregate() (or useRepository(Model).aggregate()) groups and summarizes objects without loading them in
application code:
const { rows, native } = await Task.aggregate(
{
filter: "completed = ? AND owner.uuid = ?", // WebdaQL filter, no ORDER BY / LIMIT / OFFSET
groupBy: ["status", "owner.uuid"], // dotted paths; omit for one global row
metrics: {
n: { count: "*" }, // count: "*" or a path (non-null values)
tags: { countDistinct: "tag" },
total: { sum: "points" }, // sum / avg: numeric paths only
mean: { avg: "points" },
first: { min: "createdAt" },
top: { max: "points" }
},
orderBy: [{ key: "total", direction: "DESC" }], // a groupBy path or a metric alias
limit: 10
},
[false, user] // parameters of the filter, like query()
);
// rows: [{ status: "open", "owner.uuid": "u1", n: 4, tags: 2, total: 13, mean: 3.25, first: "2026-01-05T10:00:00.000Z", top: 5 }, ...]
// native: false when the in-memory fallback ran
Rows are flat: group paths are keys as written ("owner.uuid"), metrics are keys by alias. Rows are typed from the
model: a group key keeps its attribute type, count / countDistinct / sum are numbers, avg is number | null.
Semantics, identical on every store:
nulland missing values form one group (keynull);count(path),countDistinct,sum,avg,minandmaxskip them.sumandavgalso skip non-numeric values.- Without
groupBy, exactly one global row is returned, even when nothing matches; withgroupBy, no match means no row. Over no value,countandsumare0,avg,minandmaxarenull. - An array group key groups by the whole value (no unwinding).
min/maxcompare likeorderBybelow, somaxover5and"b"is"b". Dateattributes come back as ISO strings, in group keys andmin/maxresults.- Ordering:
nullfirst inASC(last inDESC), then numbers, then strings in code unit order ("B" < "a"). Booleans and objects are ordered in a backend-defined way, and so are ties and the rows kept by alimitwithoutorderBy: order on a unique key when it matters.
Store parameters:
| Parameter | Default | Meaning |
|---|---|---|
aggregationFallback | "warn" | When the store cannot aggregate natively: allow runs in memory, warn also logs once per shape, deny throws |
maxGroups | 10000 | Most groups an aggregation may produce without limit; the in-memory fallback applies it even with a limit |
Memory, File, PostgreSQL and MongoDB aggregate natively (native: true). Other stores (DynamoDB, Firestore, …)
stream every matching object through the in-memory engine (native: false), under aggregationFallback.
Errors are AggregationErrors with a code: AGGREGATION_NOT_NATIVE (fallback denied) and
AGGREGATION_TOO_MANY_GROUPS (more than maxGroups groups). An invalid spec throws a WebdaQLError.
WebdaQL also parses the same request as a SELECT … GROUP BY statement, see
Statements.
Count
Model.count() (or useRepository(Model).count()) counts the objects matching a filter, natively on Memory, File,
PostgreSQL, SQLite and MongoDB, otherwise in memory under aggregationFallback. max caps the count:
await Task.count("status = ?", ["open"], { max: 10000 }); // { value: 1234, relation: "eq", native: true }
ORDER BY, LIMIT and OFFSET are ignored. Clients get a permission-safe total through the query count option,
see Query totals.
Slow queries
Webda monitors query execution times across all repositories and logs warnings whenever a query exceeds a configurable threshold.
Configuration
Slow query detection is configured per-store in webda.config:
{
"stores": {
"default": {
"type": "MemoryStore",
"slowQueryThreshold": 5000,
"slowQueryLogLevel": "WARN",
"slowQueryMaxLength": 1024
}
}
}
| Parameter | Default | Description |
|---|---|---|
slowQueryThreshold | 30000 | Duration in ms above which a query is considered slow. Set to 0 to disable detection. |
slowQueryLogLevel | "WARN" | Log level used when logging slow queries ("ERROR", "WARN", "INFO", "DEBUG"). |
slowQueryMaxLength | 1024 | Maximum length of the query string written to logs / events (longer queries truncated). |
Log format
When a query exceeds slowQueryThreshold, a log entry is emitted:
Slow query on store default (MyModel, query) took 5120ms [42 results]: name = 'Alice'
Brackets include execution details when available:
- Result count:
[10 results] - Pagination:
[paginated] - Execution errors:
[error: Connection timeout]
Metrics & Events
- Metrics:
<store>_queries: Histogram tracking query durations with labelsoperationandmodel.<store>_slow_queries_total: Counter incremented on every slow query with labelsoperationandmodel.
- Events:
- Stores emit
Store.SlowQuerywith payload{ operation, model, query, duration, count?, paginated?, error?, store }.
- Stores emit
Events
Repositories emit typed events around every operation. Register listeners on the
Repository — the events fire whether the write came from Model.create(),
useRepository(Model).create(), or any other path.
| Pre-event | Post-event | Payload |
|---|---|---|
Create | Created | { object_id, object } |
Update | Updated | { object_id, object, previous } |
Patch | Patched | { object_id, object, previous } |
Delete | Deleted | { object_id } |
PartialUpdate | PartialUpdated | { object_id, partial_update } |
Query | Queried | { query, results, continuationToken? } |
Aggregate | Aggregated | { query } / { query, rows, native } |
import { useRepository } from "@webda/core";
useRepository(User).on("Created", evt => {
console.log("created", evt.object_id);
});
The legacy aggregate
Store.*events and theStore/StoreParameterstypes are@internal. New code listens on the Repository's typed events instead.
Interface
The Repository<T> interface (and its segregated sub-interfaces
CoreRepository, Updatable, AtomicOperations, CollectionOperations) live
in @webda/models. The interface is frozen — concrete implementations
(MemoryRepository, PostgresRepository, …) live in their Store packages.