HTTP Client
@atscript/db-client is an HTTP client that maps 1:1 to moost-db controller endpoints. Each method corresponds to a specific HTTP request — query() is GET /query, insert() is POST /, and so on. Works in browsers, Node.js, and any runtime with fetch.
In SSR environments, Moost's fetch automatically routes local requests to handlers in-process, so the same Client instance works on both server and browser with zero configuration.
Installation
pnpm add @atscript/db-clientCreating a Client
import { Client } from "@atscript/db-client";
// Untyped — Record<string, unknown> generics
const users = new Client("/api/users");
// Type-safe — pass the Atscript model as generic
import type { User } from "./models/user.as";
const users = new Client<typeof User>("/api/users");When you provide <typeof User>, all methods become fully typed:
- Filters check field names against the model's own properties
$sortkeys are constrained to valid field names$withentries are constrained to declared navigation properties- Primary key type flows through
one()andremove() - Insert/update data is checked against the model's field types
Options
const users = new Client<typeof User>("/api/users", {
// Base URL for all requests
baseUrl: "https://api.example.com",
// Static headers
headers: { Authorization: "Bearer token123" },
// Async header factory (e.g. token refresh)
headers: async () => ({
Authorization: `Bearer ${await getToken()}`,
}),
// Custom fetch (e.g. for testing or interceptors)
fetch: myCustomFetch,
});| Option | Type | Description |
|---|---|---|
baseUrl | string | Prepended to the path for every request |
headers | Record<string, string> or () => Promise<Record<string, string>> | Default headers for every request |
fetch | typeof fetch | Custom fetch implementation |
Querying
All query methods accept a Uniquery object with filter and controls.
query
GET /query — returns all matching records. See CRUD — GET /query.
const active = await users.query({
filter: { status: "active" },
controls: { $sort: { createdAt: -1 }, $limit: 50 },
});The $search, $vector, $index, and $threshold controls are also passed through query():
// Text search
const results = await users.query({
controls: { $search: "alice" },
});
// Vector search
const similar = await posts.query({
controls: { $vector: "embedding", $search: "machine learning" },
});count
GET /query with $count: true — returns the number of matching records.
const total = await users.count({ filter: { role: "admin" } });aggregate
GET /query with $groupBy — typed aggregation. See Relations & Search — Aggregation.
const stats = await orders.aggregate({
controls: {
$groupBy: ["status"],
$select: [
"status",
{ $fn: "count", $field: "*", $as: "total" },
{ $fn: "sum", $field: "amount", $as: "revenue" },
],
},
});When $groupBy fields and $select are typed, the result type is inferred — stats[0].total is number, stats[0].status preserves the original field type.
pages
GET /pages — page-based pagination. See CRUD — GET /pages.
const page = await users.pages(
{ filter: { active: true } },
2, // page (default: 1)
25, // size (default: 10)
);
// → { data: [...], page: 2, itemsPerPage: 25, pages: 10, count: 243 }one
GET /one/:id — fetch by primary key. Returns null on 404. See CRUD — GET /one.
// Scalar PK
const user = await users.one("abc-123");
// Composite PK
const row = await users.one({ tenantId: "t1", userId: "u1" });Supports controls for projection and relation loading:
const user = await users.one("abc", {
controls: { $select: ["id", "name"], $with: ["posts"] },
});Relation Loading
Load relations using $with in controls. See Relations & Search for full syntax.
const orders = await client.query({
controls: { $with: ["customer", "items"] },
});Write Operations
Write methods are available when the server uses AsDbController (not AsDbReadableController).
insert
POST / — insert one or many records. See CRUD — POST /.
// Single insert → { insertedId }
const { insertedId } = await users.insert({
name: "Alice",
email: "alice@example.com",
});
// Batch insert → { insertedCount, insertedIds }
const { insertedCount } = await users.insert([{ name: "Alice" }, { name: "Bob" }]);update
PATCH / — partial update. Include the primary key and changed fields only. See CRUD — PATCH /.
// Single or bulk → { matchedCount, modifiedCount }
await users.update({ id: "abc", name: "Updated" });
// Bulk
await users.update([
{ id: "a", status: "active" },
{ id: "b", status: "active" },
]);Supports field operations like $inc, $dec, $mul.
replace
PUT / — full document replace. All required fields must be present. See CRUD — PUT /.
await users.replace({
id: "abc",
name: "Alice",
email: "new@example.com",
role: "admin",
});
// Bulk
await users.replace([...]);remove
DELETE /:id — remove by primary key. See CRUD — DELETE.
// Scalar PK
const { deletedCount } = await users.remove("abc");
// Composite PK
await users.remove({ tenantId: "t1", userId: "u1" });Metadata
GET /meta — fetch table/view metadata. The result is cached after the first call.
const meta = await users.meta();Response shape:
{
"searchable": true,
"vectorSearchable": false,
"searchIndexes": [{ "name": "title_idx", "type": "text" }],
"primaryKeys": ["id"],
"readOnly": false,
"relations": [{ "name": "posts", "direction": "from", "isArray": true }],
"fields": {
"id": { "sortable": true, "filterable": true },
"name": { "sortable": false, "filterable": true }
},
"type": { "...": "serialized Atscript type schema" }
}| Field | Description |
|---|---|
searchable | Table has fulltext search indexes |
vectorSearchable | Table has vector search indexes |
searchIndexes | Available search index definitions |
primaryKeys | Primary key field names |
readOnly | true for AsDbReadableController / views |
relations | Available navigation properties |
fields | Per-field capability flags (sortable, filterable) |
type | Full serialized Atscript type definition |
Error Handling
Non-2xx responses throw a ClientError with the HTTP status and structured error body. The error shape matches the server's error response format.
import { Client, ClientError } from "@atscript/db-client";
try {
await users.insert({ name: "" });
} catch (e) {
if (e instanceof ClientError) {
e.status; // 400
e.message; // "Validation failed"
e.errors; // [{ path: "name", message: "required" }]
e.body; // full server error response
}
}one() is the exception — it returns null on 404 instead of throwing.
Client-Side Validation
Write methods (insert, update, replace) automatically validate data client-side against the Atscript type fetched from /meta. This catches type errors before they reach the server.
// Throws ClientValidationError before sending the request
await users.insert({ name: 123 }); // name must be stringAccess the validator directly for form generation or custom validation:
const validator = await users.getValidator();
validator.flatMap; // Map of field paths → annotated types
validator.navFields; // Set of navigation field names
validator.validate(data, "insert"); // throws on failureRe-exported Types
The package re-exports query types from @uniqu/core for convenience:
Uniquery,UniqueryControls— query and control typesFilterExpr— filter expression typeAggregateQuery,AggregateResult— aggregation typesTypedWithRelation— relation loading type
import type { FilterExpr, Uniquery } from "@atscript/db-client";Method ↔ Endpoint Reference
| Method | HTTP | Endpoint | Returns |
|---|---|---|---|
query() | GET | /query | DataOf<T>[] |
count() | GET | /query ($count) | number |
aggregate() | GET | /query ($groupBy) | AggregateResult[] |
pages() | GET | /pages | PageResult<DataOf<T>> |
one() | GET | /one/:id or /one?k=v | DataOf<T> | null |
insert() | POST | / | TDbInsertResult or TDbInsertManyResult |
update() | PATCH | / | TDbUpdateResult |
replace() | PUT | / | TDbUpdateResult |
remove() | DELETE | /:id or /?k=v | TDbDeleteResult |
meta() | GET | /meta | MetaResponse |