Skip to main content

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:

  • null and missing values form one group (key null); count(path), countDistinct, sum, avg, min and max skip them. sum and avg also skip non-numeric values.
  • Without groupBy, exactly one global row is returned, even when nothing matches; with groupBy, no match means no row. Over no value, count and sum are 0, avg, min and max are null.
  • An array group key groups by the whole value (no unwinding). min / max compare like orderBy below, so max over 5 and "b" is "b".
  • Date attributes come back as ISO strings, in group keys and min / max results.
  • Ordering: null first in ASC (last in DESC), 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 a limit without orderBy: order on a unique key when it matters.

Store parameters:

ParameterDefaultMeaning
aggregationFallback"warn"When the store cannot aggregate natively: allow runs in memory, warn also logs once per shape, deny throws
maxGroups10000Most 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
}
}
}
ParameterDefaultDescription
slowQueryThreshold30000Duration 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").
slowQueryMaxLength1024Maximum 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 labels operation and model.
    • <store>_slow_queries_total: Counter incremented on every slow query with labels operation and model.
  • Events:
    • Stores emit Store.SlowQuery with payload { operation, model, query, duration, count?, paginated?, error?, store }.

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-eventPost-eventPayload
CreateCreated{ object_id, object }
UpdateUpdated{ object_id, object, previous }
PatchPatched{ object_id, object, previous }
DeleteDeleted{ object_id }
PartialUpdatePartialUpdated{ object_id, partial_update }
QueryQueried{ query, results, continuationToken? }
AggregateAggregated{ 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 the Store/StoreParameters types 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.