Loopar Api Reference
loopar-webpage

API Reference

Complete reference for Loopar's server-side APIs.


Core Objects

ObjectDescription
looparGlobal framework object
BaseDocumentBase class for Models
BaseControllerBase class for Controllers

Import Patterns

loopar is a named export on the server:

// In Model (.js)
import { BaseDocument, loopar } from "loopar";

// In Controller (-controller.js)
import { BaseController, loopar } from "loopar";

// In any server file
import { loopar } from "loopar";

// With filter operators
import { loopar, Op } from "loopar";

On the client (.jsx files) loopar resolves to the browser runtime, which also exposes loopar as a named export: import { loopar } from "loopar";


Quick Reference

Document Operations

const doc = await loopar.getDocument("Entity", "name");
const doc = await loopar.newDocument("Entity");
await doc.save();
await doc.delete();
const list = await loopar.getList("Entity", { filters: { status: "Active" } });

Database Queries

const all = await loopar.db.getAll("Entity", fields, filter);
const count = await loopar.db.count("Entity", filters);
const value = await loopar.db.getValue("Entity", "name", "field");

Utilities

const user = loopar.currentUser;           // logged-in user (or null)
const settings = await loopar.getSettings();
await loopar.mail.send({ to, subject, html });

loopar (Global Object)

The main framework object available throughout the server-side code.

import { loopar } from "loopar";

Document Methods

loopar.getDocument(entity, name, data?, options?)

Retrieve an existing document by name.

const customer = await loopar.getDocument("Customer", "CUST-0001");

// Access fields
console.log(customer.email);
console.log(customer.status);

// Call model methods
const fullName = customer.getFullName();

// Return null instead of throwing when missing
const maybe = await loopar.getDocument("Customer", "CUST-0001", null, { ifNotFound: null });
ParameterTypeDescription
entitystringEntity name
namestringDocument name/ID
dataobjectValues to merge onto the loaded document (optional)
optionsobject{ ifNotFound: "throw" | null } — default throws a 404

Returns: Document instance


loopar.newDocument(entity, data?)

Create a new document instance.

// Create empty
const customer = await loopar.newDocument("Customer");
customer.customer_name = "John Doe";
customer.email = "john@example.com";
await customer.save();

// Create with initial data
const task = await loopar.newDocument("Task", {
title: "New Task",
status: "Open",
priority: "High"
});
await task.save();
ParameterTypeDescription
entitystringEntity name
dataobjectInitial field values (optional)

Returns: New document instance (unsaved)


loopar.deleteDocument(entity, name, options?)

Delete a document by name.

await loopar.deleteDocument("Customer", "CUST-0001");

// Hard-delete even if other documents reference it
await loopar.deleteDocument("Customer", "CUST-0001", { force: true, sofDelete: false });
OptionDefaultDescription
sofDeletetrueSoft delete (mark as deleted) instead of removing the row
forcefalseSkip the connected-documents guard
ifNotFoundnullWhat to do when the document doesn't exist

loopar.getList(entity, options?)

Paginated list of documents (what the desk list views use).

const list = await loopar.getList("Task", {
fields: ["name", "title", "status"],
filters: { status: "Open" },
q: { title: "urgent" },   // per-field search terms
rowsOnly: false
});

// list.rows, list.pagination { page, pageSize, totalRecords, totalPages }

User & Session

loopar.currentUser

The authenticated user for the current request (or null).

const user = loopar.currentUser;

if (user) {
console.log(user.name, user.email, user.user_type);
}

loopar.auth

Auth helpers live on loopar.auth, not on loopar directly:

const user = await loopar.auth.getUser("user@example.com");
const isDisabled = await loopar.auth.disabledUser(userId);

loopar.session

A per-request key/value store (used e.g. for list filters and pagination) — not a user object.

loopar.session.get("Customerpage");   // current list page for Customer
loopar.session.set("myKey", value);
loopar.session.remove("myKey");

Configuration

loopar.getSettings()

Access system settings (cached after the first call).

// System settings document
const settings = await loopar.getSettings();

// Database connection config (sites/<tenant>/config/db.config.json)
const dbConfig = loopar.getDbConfig();

Utilities

loopar.mail.send(options)

Send an email through the built-in email service (loopar.mail).

await loopar.mail.send({
to: "customer@example.com",
subject: "Welcome!",
html: "<h1>Welcome to our platform</h1>",
// Optional
from: "noreply@yoursite.com",
cc: ["manager@yoursite.com"],
attachments: [{ filename: "doc.pdf", path: "/path/to/doc.pdf" }]
});
OptionTypeDescription
tostring/arrayRecipient(s)
subjectstringEmail subject
htmlstringHTML body
fromstringSender (optional)
ccarrayCC recipients
bccarrayBCC recipients
attachmentsarrayFile attachments

loopar.throw(error, redirect?)

Abort the current action with an error. Pass a string for a generic 400, or an object with code to control the HTTP status. The optional second argument is a redirect URL (used for auth/session failures), not a status code.

if (!customer) {
loopar.throw({ code: 404, message: "Customer not found" });
}

if (!hasPermission) {
loopar.throw({ code: 403, message: "Access denied" });
}

// String → HTTP 400
loopar.throw("Invalid data");

// With redirect (forces the client back to login)
loopar.throw("Session expired", "/auth/login");

loopar.hook(entity, event, callback)

Subscribe to persistence events for an entity — fired at the ORM layer for any write (including loopar.db.setValue-style updates from the document layer).

loopar.hook("Customer", "afterCreate", async (data) => {
await loopar.mail.send({ to: data.email, subject: "Welcome!", html: "..." });
});

Valid events: beforeSave, beforeCreate, beforeUpdate, beforeDelete, afterSave, afterCreate, afterUpdate, afterDelete (beforeSave/afterSave are shorthand for create + update).


loopar.emit(event, payload?)

Broadcast a realtime event to connected clients (Socket.IO, tenant-scoped).

loopar.emit("orders:created", { name: order.name });

loopar.db (Database API)

loopar.db is the framework's data layer — a Knex-powered query engine that works across MySQL/MariaDB, PostgreSQL, SQLite, SQL Server and Oracle.

import { loopar } from "loopar";

const results = await loopar.db.getAll("Customer");

Query Methods

loopar.db.getList(document, fields?, filter?, options?)

Get a paginated list of records.

// All fields, current page
const page = await loopar.db.getList("Customer");

// Selected fields + filter
const active = await loopar.db.getList(
"Customer",
["name", "email", "status"],
{ status: "Active" }
);

Pagination is controlled by loopar.db.pagination and loopar.db.setPage(n).


loopar.db.getAll(document, fields?, filter?)

Same as getList, but returns every matching row (no pagination).

const customers = await loopar.db.getAll(
"Customer",
["name", "email"],
{ status: "Active" }
);
ParameterTypeDescription
documentstringEntity name
fieldsarrayFields to return (default ["*"])
filterobjectWHERE conditions

loopar.db.getRow(document, filter)

Get a single raw row matching a filter, or null.

const row = await loopar.db.getRow("Customer", { email: "john@example.com" });

loopar.db.getValue(document, name, field)

Get one field value from a document.

const email = await loopar.db.getValue("Customer", "CUST-0001", "email");

loopar.db.count(document, filter)

Count records matching a filter.

const active = await loopar.db.count("Customer", { status: "Active" });

loopar.db.hasEntity(constructor, document) / loopar.db.hasTable(name)

Check whether an entity record or a physical table exists.

const exists = await loopar.db.hasTable("Customer");

Filter Conditions

Filters are plain objects. For operators, import Op from loopar:

import { loopar, Op } from "loopar";

// Exact match (AND between keys)
{ status: "Active", priority: "High" }

// Operators
{ amount: { [Op.gt]: 1000 } }
{ created_at: { [Op.gte]: "2024-01-01" } }
{ name: { [Op.like]: "%john%" } }
{ status: { [Op.in]: ["Open", "In Progress"] } }

Modify Methods

loopar.db.setValue(document, name, field, value)

Update a single field directly.

await loopar.db.setValue("Customer", "CUST-0001", "status", "VIP");

Note: This bypasses model hooks. Use doc.save() for the full lifecycle.


loopar.db.insertRow / updateRow / deleteRow / deleteWhere

Low-level row operations used by the document layer. In application code, prefer doc.save() and doc.delete().

await loopar.db.deleteWhere("Log", { created_at: { [Op.lt]: "2024-01-01" } });

Raw SQL

loopar.db.raw(sql, replacements?)

Run a parameterized raw query.

const stats = await loopar.db.raw(`
SELECT project, COUNT(*) AS task_count
FROM Task
WHERE created_at > ?
GROUP BY project
`, ["2024-01-01"]);

Warning: Always use ? placeholders with the replacements array to prevent SQL injection.


Transactions

await loopar.db.beginTransaction();
try {
const order = await loopar.newDocument("Order", { customer: "CUST-0001" });
await order.save();
await loopar.db.endTransaction();   // commit
} catch (e) {
await loopar.db.safeRollback();
throw e;
}
MethodDescription
beginTransaction()Start a transaction
endTransaction()Commit the transaction
safeRollback()Roll back safely

BaseDocument

Base class for all Models. Extend this to add custom logic to your entities.

// customer.js (MODEL)
import { BaseDocument } from "loopar";

export default class Customer extends BaseDocument {
constructor(props) {
super(props);
}
}

Instance Properties

Field Values

Access and set field values directly.

// Read fields
const name = this.customer_name;
const email = this.email;
const status = this.status;

// Set fields
this.status = "Active";
this.total = this.subtotal + this.tax;

this.name

The document's unique identifier.

console.log(this.name); // "CUST-0001"

this.IS_NEW

Whether this is a new (unsaved) document.

async beforeSave() {
if (this.__IS_NEW__) {
this.status = this.status || "Draft";
}
}

Current user

Models don't carry a session object — use the global:

import { loopar } from "loopar";

async beforeSave() {
this.modified_by = loopar.currentUser?.name;
}

Instance Methods

this.save(options?)

Save the document to database (insert when new, update otherwise). Child tables, file uploads and the owning app's version bump are handled automatically.

// Simple save
await this.save();

// Skip validation
await this.save({ validate: false });

// Force re-writing child table rows (used by installers)
await this.save({ forceChildren: true });

Execution order: beforeSave()validate() (unless validate: false) → INSERT/UPDATE → child tables → file uploads → afterSave()


this.delete(options?)

Delete the document. Fails with a validation error if other documents reference it, unless force is set.

await this.delete();

await this.delete({ force: true, sofDelete: false }); // hard delete

Triggers: connected-documents guard → DB delete → afterDelete()


this.values() / this.rawValues()

Get the document's data as a plain object (values() resolves formatted/computed values; rawValues() returns stored values).

const data = await this.values();
// { name: "CUST-0001", customer_name: "John", email: "john@...", ... }

Lifecycle Hooks

Override these methods to add custom logic. These are the hooks the document layer actually calls:

HookWhen
onLoad()After the document is loaded
beforeSave()Before any save (insert OR update)
validate()During save, unless save({ validate: false })
afterSave()After any save (insert OR update)
afterDelete()After a delete

There are no beforeInsert/afterInsert/beforeUpdate/afterUpdate/beforeDelete methods on the model. For create/update/delete granularity, subscribe with loopar.hook(entity, event, callback) (events: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete) or branch on this.__IS_NEW__ inside beforeSave()/afterSave().

async beforeSave()

async beforeSave() {
// Calculate totals
this.total = this.subtotal + this.tax - this.discount;

// Update full name
this.full_name = `${this.first_name} ${this.last_name}`;

if (this.__IS_NEW__) {
this.status = this.status || "Draft";
}
}

async validate()

Validate before any save operation. The base implementation validates every field against the entity metadata (required, unique, format...) — call super.validate() to keep it.

async validate() {
await super.validate();

if (this.amount < 0) {
loopar.throw("Amount cannot be negative");
}

// Check for duplicates
const existing = await loopar.db.getRow("Customer", { email: this.email });
if (existing && existing.name !== this.name) {
loopar.throw("Email already exists");
}
}

async afterSave()

async afterSave() {
if (this.__IS_NEW__) {
// Send welcome email on first save
await loopar.mail.send({
to: this.email,
subject: "Welcome!",
html: `Hello ${this.customer_name}!`
});
}
}

async afterDelete()

async afterDelete() {
console.log(`Customer ${this.name} deleted`);
}

ORM-level hooks (any entity, from anywhere)

import { loopar } from "loopar";

loopar.hook("Order", "afterCreate", async (data) => {
await loopar.newDocument("Notification", {
title: `New order ${data.name}`
}).then(n => n.save());
});

BaseController

Base class for Controllers. Only methods prefixed with action (or publicAction) are accessible via URL.

// customer-controller.js (CONTROLLER)
import { BaseController, loopar } from "loopar";

export default class CustomerController extends BaseController {

// GET/POST /api/Customer/stats
async actionStats() {
return { total: 100 };
}
}

URL Routing

The URL segment is the literal method name without its prefix — only the first letter is case-insensitive. There is no kebab-case conversion.

Method NameURLAccess
actionView/api/Entity/view?name=XLogged-in + permission
actionCreate/api/Entity/createLogged-in + permission
actionStats/api/Entity/statsLogged-in + permission
actionSendEmail/api/Entity/sendEmailLogged-in + permission
publicActionTrack/api/Entity/trackPublic (no auth)
helperMethodNOT accessible

Naming: actionMyAction/api/Entity/myAction. When both exist, publicActionX takes precedence over actionX for the URL segment x.

The same controller serves every workspace: /desk/Entity/action renders the desk UI, /api/Entity/action returns JSON.


Instance Properties

this.data

Request data (POST body, including multipart fields and files).

async actionCreate() {
const { name, email, status } = this.data;

const customer = await loopar.newDocument("Customer", {
customer_name: name,
email,
status
});
await customer.save();

return this.success("Customer created", { name: customer.name });
}

this.query

Query-string parameters. Query params are also spread onto the controller instance (e.g. ?name=X sets this.name).

async actionView() {
const name = this.query.name;   // same as this.name
const document = await loopar.getDocument(this.document, name);
return await this.render(document);
}

this.document / this.action

The entity name and action resolved from the URL (/{workspace}/{document}/{action}).


this.req / this.res

The underlying Express request and response.

async actionExport() {
this.res.setHeader("Content-Type", "text/csv");
this.res.setHeader("Content-Disposition", "attachment; filename=customers.csv");
return csvString;
}

Current user

import { loopar } from "loopar";

async actionProfile() {
const user = loopar.currentUser;

if (!user) {
loopar.throw({ code: 401, message: "Not authenticated" });
}

return await loopar.getDocument("User", user.name);
}

Response Helpers

HelperResult
this.success(message, extra?){ status: 200, success: true, message, ...extra, notify }
this.error(message, extra?, status?){ status, code, title, message, ...extra, notify }
this.redirect(url)Client-side redirect
this.render(meta)Render a page (non-API workspaces)
this.notFound({...})404 payload/page

Pass notify: false in extra to suppress the toast.


Built-in Actions

Every entity controller inherits these from BaseController:

ActionURLDescription
list/api/Entity/listPaginated rows (default action)
view/api/Entity/view?name=XSingle document
create/api/Entity/createNew document (POST data = fields)
update/api/Entity/update?name=XSave changes (POST data = fields)
delete/api/Entity/delete?name=XDelete document
bulkDelete/api/Entity/bulkDeleteDelete many (names in body)
search/api/Entity/searchSelect/autocomplete search

Action Examples

Query Action

// GET /api/Customer/topCustomers?status=Active
async actionTopCustomers() {
const { status } = this.query;

const filters = {};
if (status) filters.status = status;

const customers = await loopar.db.getAll("Customer", ["name", "email"], filters);
return { customers };
}

Action with Document

// POST /api/Customer/upgradeToVip  (body: { name: "CUST-0001" })
async actionUpgradeToVip() {
const { name } = this.data;

if (!name) {
loopar.throw("Customer name required");
}

const customer = await loopar.getDocument("Customer", name);
customer.status = "VIP";
await customer.save();

return this.success(`${customer.customer_name} is now VIP`);
}

Stats/Aggregation Action

// GET /api/Task/dashboard
async actionDashboard() {
const user = loopar.currentUser?.name;

const [open, inProgress, completed] = await Promise.all([
loopar.db.count("Task", { status: "Open", assigned_to: user }),
loopar.db.count("Task", { status: "In Progress", assigned_to: user }),
loopar.db.count("Task", { status: "Completed", assigned_to: user }),
]);

return { open, inProgress, completed, total: open + inProgress + completed };
}

Public Action (no auth)

// POST /api/Contact Message/submit — reachable without login
async publicActionSubmit() {
const message = await loopar.newDocument("Contact Message", this.data);
await message.save();
return this.success("Thanks! We'll get back to you.");
}

publicAction* methods skip the login, permission and CSRF checks — validate their input carefully.


Error Handling

async actionRiskyOperation() {
try {
await this.performOperation();
return this.success("Done");
} catch (error) {
console.error(error);
loopar.throw({ code: 500, message: error.message });
}
}

Declaring exposure

Static class fields fine-tune which actions are available:

export default class TaskController extends BaseController {
// Extra inherited actions to enable on this entity's views
static enabledActions = ["archive"];
}

HTTP API

Every entity controller is reachable over HTTP. The URL follows one convention everywhere:

/{workspace}/{Entity}/{action}
WorkspacePurposeAuth
web (no prefix)Public website pagesNone
deskAdmin UILogin + CSRF, desk users only
apiJSON surfaceLogin + CSRF (except publicAction*)
portalAuthenticated end-user appLogin + CSRF
authLogin/register/recovery

The action comes from the URL path, not from the HTTP verb. GET and POST both dispatch the same action; use POST when sending data (body fields become this.data).


Calling Built-in Actions

# List (paginated)
GET /api/Customer/list

# Single document
GET /api/Customer/view?name=CUST-0001

# Create (POST fields as JSON or form-data)
POST /api/Customer/create
Content-Type: application/json

{ "customer_name": "New Customer", "email": "new@example.com", "status": "Lead" }

# Update
POST /api/Customer/update?name=CUST-0001
Content-Type: application/json

{ "status": "VIP" }

# Delete
POST /api/Customer/delete?name=CUST-0001

# Bulk delete
POST /api/Customer/bulkDelete
{ "names": ["CUST-0001", "CUST-0002"] }

List pagination & filters

List state is session-backed (it's what the desk infinite list uses). Send page and per-field search terms in q as POST data:

POST /api/Customer/list
Content-Type: application/json

{ "page": 2, "q": { "status": "Active", "customer_name": "john" } }

List response:

{
"labels": ["Name", "Email", "Status"],
"fields": ["name", "email", "status"],
"rows": [
{ "id": 1, "name": "CUST-0001", "email": "john@...", "status": "Active" }
],
"pagination": {
"page": 1,
"pageSize": 10,
"totalRecords": 150,
"totalPages": 15,
"sortBy": "id",
"sortOrder": "asc"
},
"q": { "status": "Active" }
}

For programmatic queries with full control (fields, filters, ordering, limits), write a custom action that calls loopar.getList() or loopar.db.getAll() — that's the intended pattern.


Custom Actions

A controller method becomes a URL with its prefix stripped — no kebab-case conversion, the segment is the literal method suffix:

async actionStats() { ... }         // GET/POST /api/Customer/stats
async actionMarkComplete() { ... }  // GET/POST /api/Task/markComplete
async publicActionTrack() { ... }   // GET/POST /api/Page View/track — public
# GET action
GET /api/Customer/stats

# POST action with data
POST /api/Task/markComplete
Content-Type: application/json

{ "name": "TASK-0001" }

Success & Error Responses

Actions returning via this.success():

{
"status": 200,
"success": true,
"message": "Customer CUST-0001 saved successfully",
"name": "CUST-0001",
"notify": { "type": "success", "message": "..." }
}

Errors (thrown with loopar.throw or returned via this.error()):

{
"status": 404,
"code": 404,
"title": "Not Found",
"message": "Customer not found"
}

The HTTP status code mirrors the payload's status/code (clamped to 100–599).

StatusMeaning
400Bad request / Validation error
401Authentication required
403Permission denied / Invalid CSRF
404Document or action not found
500Server error

Authentication

The /api workspace uses cookie-based sessions, not bearer tokens:

  1. Log in via POST /auth/login — the server sets an httpOnly JWT cookie (loopar_token_<tenant>) and a readable csrf-token cookie.
  2. Send the cookies with every request.
  3. For non-GET requests, echo the CSRF cookie in the x-csrf-token header (double-submit).
# 1. Login (saves cookies)
curl -c cookies.txt -X POST http://localhost:3000/auth/login \
-d "email=admin@example.com&password=secret"

# 2. Authenticated call with CSRF header
CSRF=$(grep csrf-token cookies.txt | awk '{print $7}')
curl -b cookies.txt -H "x-csrf-token: $CSRF" \
-X POST http://localhost:3000/api/Customer/update?name=CUST-0001 \
-H "Content-Type: application/json" \
-d '{"status": "VIP"}'

Actions declared as publicAction* skip login and CSRF entirely — that's the mechanism for webhooks and public endpoints.

Client-Side API

Utilities for client-side code (.jsx files inside an entity's client/ folder).


Client Contexts

An entity's client files export a class extending the context that matches the view:

// client/task-form.jsx
import FormContext from "@context/form-context";

export default class TaskForm extends FormContext {
// customize the form view
}
ContextImportView
FormContext@context/form-contextCreate/update form
ListContext@context/list-contextList view
ViewContext@context/view-contextRead-only view
ReportContext@context/report-contextReport/table view

Form Values (FormContext)

Form contexts wrap react-hook-form. Read and write values with:

export default class TaskForm extends FormContext {
markComplete() {
this.setValue("status", "Completed");
this.save();   // sends the form through the entity's update action
}

get statusLabel() {
return this.getValue("status");
}
}
MethodDescription
this.getValue(field)Current field value
this.setValue(field, value)Set a field (marks the form dirty)
this.save()Submit through the document's action
this.hasChanges()Any dirty fields?

useDocument Hook

Inside custom components rendered within a document view, useDocument() exposes the document context:

import { useDocument } from "@context/@/document-context";

function Header() {
const { name, entity, Document, sidebarOpen, handleSetSidebarOpen } = useDocument();

return <h1>{entity}: {name}</h1>;
}
PropertyDescription
nameDocument name
entityEntity name
DocumentDocument metadata (entity, values, structure)
docRefRef to the active form/view instance
sidebarOpen / handleSetSidebarOpenSidebar state

The client loopar object

import { loopar } from "loopar";

loopar.call(Document, action, options?)

The request method — RPC in the literal sense: invoke a controller action as if it were a local function. The URL is always POST /{Document}/{action}, no matter which workspace you are browsing (the browsing context travels in the signed X-Workspace-Token header):

// Simple call
const stats = await loopar.call("Customer", "stats");

// With data (JSON body)
const result = await loopar.call("Task", "markComplete", {
body: { name: "TASK-0001" }
});

// Callback style — without callbacks it returns a Promise
loopar.call("Customer", "stats", { success: (r) => console.log(r) });

Options: { body, query, success, error, always, freeze }.

Non-public actions require session + permission + CSRF; an action is public only when its controller declares publicAction<Name>.

There is no client-side api/rpc surface anymore — Loopar calling itself is always first-party. The /api/{Document}/{action} channel still exists on the server, but only for third parties (external integrations, webhooks, server-to-server).

loopar.navigate(path)

loopar.navigate("/desk/Customer/list");
loopar.navigate(`/desk/Customer/update?name=${name}`);

loopar.notify(message, type?)

loopar.notify("Saved!", "success");
loopar.notify("Something failed", "error");

UI Components

Loopar ships shadcn/ui components under the @cn alias, plus its own element components:

import { Button } from "@cn/components/ui/button";
import { Badge } from "@cn/components/ui/badge";
import { cn } from "@cn/lib/utils";
import { Link } from "@link";
import { CheckCircle, AlertTriangle } from "lucide-react";

Example: Custom Form

// client/task-form.jsx
import FormContext from "@context/form-context";
import { loopar } from "loopar";

export default class TaskForm extends FormContext {
async markComplete() {
this.setValue("status", "Completed");
this.setValue("completed_at", new Date().toISOString());
this.save();
loopar.notify("Task completed", "success");
}
}