Cheatsheet
Minimal, complete patterns for SDK @orbito/api-lib 4.3.0. Every snippet is a whole file of an example module acme/notes (CommonJS, like all Orbito modules) and was run against the real SDK with a stubbed core. Copy a snippet, then rename collections, methods and the module code.
The SDK exports:
const {
Core, Config, DB, Pages, Utils, Log, Mail, Files, Problems,
History, Automation, Templates, Docs, OAuth, Time,
} = require('@orbito/api-lib');
Reference in Polish for anything not covered here: SDK API.
Method with params and validation
'use strict';
const Validator = require('fastest-validator');
const { Core, DB } = require('@orbito/api-lib');
// The schema documents the method (developer panel, AI assistant) and validates the input.
// The core only stores it - without the explicit check the handler would trust anything.
const createNoteParams = {
title: { type: 'string', trim: true, min: 1, max: 200 },
body: { type: 'string', optional: true, max: 5000 },
priority: { type: 'number', integer: true, min: 1, max: 5, optional: true, convert: true },
};
const checkCreateNote = new Validator().compile(createNoteParams);
Core.registerMethod('createNote', {
description: 'Creates a note and returns its id.',
params: createNoteParams,
returns: { type: 'object', props: { _id: 'string' } },
throws: [{ code: 'INVALID_PARAMS', message: 'Params do not match the schema' }],
handler: async (params, ctx) => {
const note = { ...params };
const check = checkCreateNote(note); // also trims `title` and converts `priority`
if (check !== true) {
throw new Error(`INVALID_PARAMS: ${check.map((error) => error.message).join('; ')}`);
}
const insertedId = await DB.insert('notes', {
title: note.title,
body: note.body || '',
priority: note.priority || 3,
createdBy: ctx.userId || null,
createdAt: new Date(),
});
return { _id: String(insertedId) };
},
});
// Permissions: register the code once, check it in every handler that needs it.
Core.registerPermission('notes.delete', 'Delete notes');
Core.registerMethod('deleteNote', {
description: 'Deletes one note.',
params: { noteId: { type: 'string', length: 24 } },
handler: async (params, ctx) => {
if (!Core.hasPermission('notes.delete', ctx.permissions)) {
throw new Error('PERMISSION_DENIED');
}
const noteId = String((params || {}).noteId || '');
if (!/^[0-9a-f]{24}$/i.test(noteId)) {
throw new Error('INVALID_PARAMS: noteId must be a 24-character hex id');
}
const deleted = await DB.delete('notes', { _id: new DB.ObjectId(noteId) });
return { deleted };
},
});
- The handler gets
(params, ctx); the samectxis bound asthis.paramsis the one object the caller sent (the REST body,callMethodparams in OVE,--paramsofomm call). ctx.userIdis the user who triggered the call,ctx.permissionstheir permissions in this module (short codes,'*'for owners and admins),ctx.callerthe calling module instance ({ instanceId, moduleCode, instanceName }) ornullwhen the core called (user, automation),ctx.originthe automation run ornull.params,returnsandthrowsare documentation for the developer panel and the AI assistant. The core never validates params: compile the schema with fastest-validator and check it in the handler.- The return value travels as JSON:
ObjectIdbecomes a hex string,Datean ISO string. A thrownErrorreaches the caller with its message. - Over REST the result is wrapped in a one-element array (
[{ "_id": "65f0c3a1e4b0a1b2c3d4e5f6" }]): OVE reads it as$response.0;omm callunwraps it and prints the element. - Many methods: put one file per method in
methods/, each exporting the handler function or a{ description, params, handler }object, and callUtils.loadMethods(path.join(__dirname, 'methods')). Subdirectories become dotted names:methods/jobs/claim.jsisjobs.claim.
Hooks: registerHook and execHook
Module acme/reminders answers a hook:
'use strict';
const { Core, DB, Log } = require('@orbito/api-lib');
// Module acme/reminders answers the hook fired by acme/notes.
// A regular `function` receives the call context as `this` (an arrow function ignores it).
Core.registerHook('acme.notes.badges', async function (note) {
try {
const count = await DB.count('reminders', { noteId: note.noteId });
if (count === 0) {
return null;
}
return { label: `${count} reminder(s)`, color: 'blue' };
} catch (error) {
// A handler that throws contributes no answer and the caller never learns why: log it.
Log.error('acme.notes.badges failed', {
error: error.message,
caller: this.caller ? this.caller.moduleCode : 'core',
});
return null;
}
});
Module acme/notes fires it:
'use strict';
const { Core } = require('@orbito/api-lib');
// Module acme/notes fires the hook and collects the answers of every module that registered it.
Core.registerMethod('noteBadges', {
description: 'Collects badges for a note from all modules answering acme.notes.badges.',
params: { noteId: { type: 'string', min: 1 } },
handler: async (params) => {
const noteId = (params || {}).noteId;
if (typeof noteId !== 'string' || noteId === '') {
throw new Error('INVALID_PARAMS: noteId is required');
}
// One flat array with the answer of every handler in every installed module
// (all instances); [] when nobody listens. Drop empty answers.
const answers = await Core.execHook('acme.notes.badges', { noteId });
return answers.filter(Boolean);
},
});
- A hook reaches every enabled module of the same company that registered it, every instance of each, sleeping containers included. Answers come back as one flat array;
[]when nobody listens. - Handler arguments are exactly what the caller passed to
execHook. The context (this.caller,this.origin) is bound asthisonly on a regularfunction; an arrow function or.bind()loses it. - A handler that throws contributes no answer. Catch, log and return
null. - Prefer hooks between modules. A direct call is
Core.callModule('acme/reminders', 'method', params); it fails witherr.code === 'AMBIGUOUS_MODULE_TARGET'when the company has several instances of the target, then useCore.callInstanceMethod(instanceId, 'method', params).
Database (DB)
'use strict';
const { Core, DB } = require('@orbito/api-lib');
// A collection name is logical: the core prefixes it per instance, never add companyId.
// Fields are required unless `optional: true` (fastest-validator rules); `create` validates.
const Notes = new DB.Schema('notes', {
title: { type: 'string', min: 1, max: 200 },
body: { type: 'string', optional: true, default: '' },
priority: { type: 'number', integer: true, min: 1, max: 5, optional: true, default: 3 },
tags: { type: 'array', items: 'string', optional: true, default: [] },
createdAt: { type: 'date', optional: true, default: () => new Date() },
});
// Every CRUD call of DB.Schema in one place.
async function crudTour() {
const created = await Notes.create({ title: 'Call the supplier' }); // the stored document with its new _id
const id = String(created._id);
const one = await Notes.findOne({ _id: id }); // a 24-hex `_id` string is cast to ObjectId
const page = await Notes.find({ priority: { $gte: 3 } }, 20, 0, { createdAt: -1 }); // query, limit, offset, sort
const total = await Notes.count({ priority: { $gte: 3 } });
await Notes.update({ _id: id }, { priority: 5 }); // no `$` operator = `$set`
await Notes.update({ _id: id }, { $push: { tags: 'urgent' } }); // any MongoDB update operator
const removed = await Notes.delete({ _id: id }); // -> number of deleted documents
return { one, page, total, removed };
}
Core.registerMethod('listNotes', {
description: 'Lists notes for the OVE datatable: search, sort and pagination.',
params: {
query: { type: 'string', optional: true },
page: { type: 'number', optional: true },
perPage: { type: 'number', optional: true },
sortBy: { type: 'string', enum: ['title', 'priority', 'createdAt'], optional: true },
sortDir: { type: 'string', enum: ['asc', 'desc'], optional: true },
},
handler: async (params) => {
const input = params || {};
const page = Math.max(1, parseInt(input.page, 10) || 1);
const perPage = Math.min(100, Math.max(1, parseInt(input.perPage, 10) || 25));
const sortBy = ['title', 'priority', 'createdAt'].includes(input.sortBy) ? input.sortBy : 'createdAt';
const sortDir = input.sortDir === 'asc' ? 1 : -1;
const where = {};
if (typeof input.query === 'string' && input.query.trim() !== '') {
const escaped = input.query.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
where.title = { $regex: escaped, $options: 'i' };
}
const [data, total] = await Promise.all([
Notes.find(where, perPage, (page - 1) * perPage, { [sortBy]: sortDir }),
Notes.count(where),
]);
return { data, total };
},
});
// Indexes are idempotent; a failure is logged, not thrown. Call this from your one
// Core.onEnabled handler (see Lifecycle below).
async function createNoteIndexes() {
await DB.createIndex(Notes.name, { createdAt: -1 });
await DB.createIndex(Notes.name, { title: 1 });
}
module.exports = { Notes, crudTour, createNoteIndexes };
- Collections are per instance: two installations never see each other's data. Do not add
companyIdto documents or indexes. DB.Schemavalidates with fastest-validator oncreateonly (fields are required unlessoptional: true), appliesdefaults and casts query values to the field types.updatedoes not validate.find(query, limit = 100, offset = 0, sort = {})returns plain objects, never more thanlimit.- Without a schema use the raw calls on a collection name:
DB.find,DB.insert(returns the new_id),DB.update,DB.delete(returns the count),DB.count,DB.createIndex. Build ids withnew DB.ObjectId(hex). - The
listNoteshandler returns{ data, total }and readsquery,page,perPage,sortBy,sortDir: exactly what the datatable of the OVE page sends.
Config block
'use strict';
const { Config, Core } = require('@orbito/api-lib');
const { PropertyType, DisplayFormat, ActionStyle } = Config;
// Orbito renders a settings form from this (Settings -> Modules -> Configure) and saves
// the values itself under `blockKey.fieldKey` keys: `connection.apiUrl`, `connection.apiKey`.
Config.registerConfigBlocks({
connection: {
title: { en: 'Connection', pl: 'Połączenie' },
scope: 'global', // one set of values per instance; 'user' = every user has own values
schema: {
help: {
type: PropertyType.INFO,
format: DisplayFormat.MARKDOWN,
name: { en: 'Where to find the key', pl: 'Gdzie znaleźć klucz' },
description: { en: 'Acme panel -> **Settings** -> **API**.', pl: 'Panel Acme -> **Ustawienia** -> **API**.' },
},
apiUrl: {
type: PropertyType.STRING,
format: DisplayFormat.URL,
name: { en: 'API address', pl: 'Adres API' },
required: true,
},
apiKey: {
type: PropertyType.STRING,
format: DisplayFormat.SECRET, // never sent back to the browser; empty on save = keep
name: { en: 'API key', pl: 'Klucz API' },
required: true,
},
pageSize: {
type: PropertyType.NUMBER,
format: DisplayFormat.POSITIVE_INTEGER,
name: { en: 'Page size', pl: 'Rozmiar strony' },
},
},
validate: async (params, raport) => {
if (params.apiUrl && !/^https:\/\//.test(params.apiUrl)) {
raport.addError('apiUrl', 'The address must start with https://');
}
},
actions: {
testConnection: {
title: { en: 'Test connection', pl: 'Testuj połączenie' },
style: ActionStyle.PRIMARY,
handler: async (actualConfig) => {
const response = await Core.fetch(`${actualConfig.apiUrl}/ping`, {
headers: { Authorization: `Bearer ${actualConfig.apiKey}` },
timeout: 10000,
});
const ok = response.status === 200;
return { success: ok, message: ok ? 'Connected' : `HTTP ${response.status}`, needReload: false };
},
},
},
},
});
// Read the saved values anywhere in the module.
async function readConnection() {
return {
apiUrl: await Config.get('connection.apiUrl'),
apiKey: await Config.get('connection.apiKey'),
pageSize: await Config.get('connection.pageSize', 50), // default when the key was never saved
};
}
// Plain key-value storage of the instance (max 64 KB per value) and per user.
async function rememberSync(userId) {
await Config.set('sync.lastRunAt', new Date());
await Config.forUser(userId).set('ui.lastTab', 'notes');
}
module.exports = { readConnection, rememberSync };
- Orbito renders the form from the schema; the module never builds a settings UI. Field types:
PropertyType.STRING | NUMBER | BOOLEAN | DATETIME | ARRAY | INFO; formats inDisplayFormat(SECRET,URL,EMAIL,TEXT,MARKDOWN,POSITIVE_INTEGER,DATE_ONLYand more). title,nameanddescriptionare a string or a language map;title.enis required.validate(params, raport)runs before the core saves; oneraport.addError(field, message)rejects the save.- An action handler gets the saved values and returns
{ success, message, needReload }(all optional). A thrown error becomessuccess: falsewith its message. - Values:
Config.get('connection.pageSize', 50)(block key, dot, field key); user-scoped blocks and per-user values:Config.forUser(ctx.userId).
Problems
'use strict';
const { Core, Problems } = require('@orbito/api-lib');
// Report in the catch, resolve after success, with a deterministic key per operation.
// Neither call ever throws, so reporting cannot hide the original error.
async function syncNote(note) {
const problemKey = `notes.sync:${note._id}`;
try {
const response = await Core.fetch('https://api.acme.example/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: note.title }),
});
if (response.status >= 400) {
throw new Error(`Acme API answered HTTP ${response.status}: ${response.text().slice(0, 200)}`);
}
await Problems.resolve({ problemKey });
return response.json();
} catch (error) {
await Problems.report({
problemKey,
code: 'NOTE_SYNC_FAILED',
desc: {
en: `The note could not be sent to Acme: ${error.message}`,
pl: `Nie udało się wysłać notatki do Acme: ${error.message}`,
},
link: { module: 'acme/notes', path: '/notes' }, // the page the "open" button leads to
entityName: note.title,
});
throw error;
}
}
module.exports = { syncNote };
- A problem is something the user has to know about and usually fix. The core shows it in the settings and counts it in the header bell.
problemKeydeduplicates per instance: a repeatedreportupdates the entry and puts it back towaiting;resolvedeletes it (a missing key is a no-op). Never use a random key.descis a string or{ en, pl };linkis{ module, path }of one of your pages;entityNamelabels the object.- Read them while testing with
omm problems --json.
History
'use strict';
const { Core, DB, History } = require('@orbito/api-lib');
Core.registerMethod('setNotePriority', {
description: 'Changes the priority of a note and records it in the company history.',
params: {
noteId: { type: 'string', length: 24 },
priority: { type: 'number', integer: true, min: 1, max: 5 },
},
handler: async (params, ctx) => {
const { noteId, priority } = params || {};
if (!/^[0-9a-f]{24}$/i.test(String(noteId)) || ![1, 2, 3, 4, 5].includes(priority)) {
throw new Error('INVALID_PARAMS: noteId (24 hex chars) and priority (1-5) are required');
}
const result = await DB.update('notes', { _id: new DB.ObjectId(noteId) }, { $set: { priority } });
if (result.matchedCount === 0) {
throw new Error('NOTE_NOT_FOUND');
}
// Append-only and never throws. The entry is finished text for people, not a log line.
await History.add({
code: 'NOTE_PRIORITY_CHANGED',
desc: { en: `Priority set to ${priority}`, pl: `Priorytet zmieniony na ${priority}` },
relations: [{ module: 'acme/notes', object: 'note', id: noteId }],
userId: ctx.userId, // omit for system events (cron, incoming integration)
});
return { noteId, priority };
},
});
// Read the history of one entity: the relation filter is the whole module/object/id triple.
Core.registerMethod('noteHistory', {
description: 'Returns the newest history entries of a note.',
params: { noteId: { type: 'string', length: 24 } },
handler: async (params) => {
const noteId = String((params || {}).noteId || '');
const { items, pagination } = await History.list({ module: 'acme/notes', object: 'note', id: noteId, limit: 50 });
return { items, total: pagination.total };
},
});
- History is the company's audit trail, shown in Settings → History. Entries are never updated or deleted.
relations(1-10 items of{ module, object, id },objectmatching^[a-z][a-z0-9_-]*$,ida string) say what the event is about;History.listfilters by the full triple.History.addnever throws (returnsnullon failure);History.listthrows on failure.- Inside an automation run the entry is attributed to the automation automatically.
Automation elements (module side)
'use strict';
const { Core, DB, Automation } = require('@orbito/api-lib');
// Armed automations of this instance: { actionId -> trigger params }. The core sends
// enable/disable only for automations pointing at this instance; clear empties all on reload.
const noteCreatedListeners = new Map();
Automation.registerTrigger('note_created', {
displayName: 'Note created',
desc: 'Fires when a note with at least the given priority is created.',
emits: {
NOTE_ID: { type: 'ObjectId', name: 'Note' }, // own key, stored as acme/notes:NOTE_ID
},
params: {
minPriority: {
type: Automation.PropertyType.NUMBER,
format: Automation.DisplayFormat.POSITIVE_INTEGER,
name: { en: 'Minimum priority', pl: 'Minimalny priorytet' },
},
},
enable(params, actionId) {
noteCreatedListeners.set(String(actionId), params);
},
disable(actionId) {
noteCreatedListeners.delete(String(actionId));
},
clear() {
noteCreatedListeners.clear();
},
});
// Call it from the code that creates the note (a method, a hook, a polling job).
async function fireNoteCreated(note) {
for (const [actionId, params] of noteCreatedListeners) {
if (Number(note.priority || 0) < Number(params.minPriority || 0)) {
continue;
}
// The event key becomes the run id: unique per event and automation, a repeat is dropped.
await Automation.callTrigger('note_created', actionId, `${actionId}:note_created:${note._id}`, {
NOTE_ID: String(note._id), // the same keys as in emits
});
}
}
Automation.registerAction('check_note_priority', {
displayName: 'Is the note urgent?',
desc: 'Compares the priority of the note from the event with a threshold.',
params: {
threshold: {
type: Automation.PropertyType.NUMBER,
format: Automation.DisplayFormat.POSITIVE_INTEGER,
name: { en: 'Urgent from priority', pl: 'Pilna od priorytetu' },
required: true,
},
},
outputs: [
{ branch: 'true', label: 'Urgent' },
{ branch: 'false', label: 'Not urgent' },
],
// Own keys short; another module's key needs its prefix, e.g. 'core/orders:ORDER_ID'.
requires: ['NOTE_ID'],
execute: async (params, ctx) => {
// Full keys always; keys of this module also short.
const noteId = ctx.event.params['acme/notes:NOTE_ID'];
const [note] = await DB.find('notes', { _id: new DB.ObjectId(noteId) }, 1);
if (!note) {
throw new Error(`NOTE_NOT_FOUND: ${noteId}`); // the run ends as errored
}
return Number(note.priority || 0) >= Number(params.threshold) ? 'true' : 'false';
},
});
Core.registerMethod('createNote', {
description: 'Creates a note and fires the note_created trigger.',
params: { title: { type: 'string', min: 1, max: 200 }, priority: { type: 'number', integer: true, min: 1, max: 5 } },
handler: async (params) => {
const { title, priority } = params || {};
if (typeof title !== 'string' || !title.trim() || ![1, 2, 3, 4, 5].includes(priority)) {
throw new Error('INVALID_PARAMS: title and priority (1-5) are required');
}
const _id = await DB.insert('notes', { title: title.trim(), priority });
await fireNoteCreated({ _id, priority });
return { _id: String(_id) };
},
});
- The core adds
__instanceIdto every element's params and strips it before your handlers run; declaring a param with that name throws at registration.enable,disableandclearare all required. paramsuses the automation schema (Automation.PropertyType,Automation.DisplayFormat,options.valuesfor a fixed list), not fastest-validator. Only graphs saved byommare checked against it, the editor in the panel saves anything: check the values you use in the handler.executereturns the branch name fromoutputs('true','false', your own); withoutoutputsthe run goes onnextwhatever it returns. A thrown error ends the run aserrored.- An action that has to wait calls
Automation.waitAndRetry(seconds)(run the action again later; keepexecuteidempotent) orAutomation.delay(seconds)(continue onnextlater). - Test the elements on a draft: compose an automation with
omm automation(Company automations). Full reference in Polish: Automation.
Lifecycle: Core.onEnabled
'use strict';
const { Core, DB, Log } = require('@orbito/api-lib');
const CLEANUP_EVERY_MS = 60 * 60 * 1000;
let cleanupTimer = null;
const KEEP_DAYS = 90;
async function removeOldNotes() {
// While a new version rolls out, the old and the new container can run at once.
const lockId = await Core.getLock('notes:cleanup', 300); // TTL in seconds
if (!lockId) {
return;
}
try {
const cutoff = new Date(Date.now() - KEEP_DAYS * 24 * 60 * 60 * 1000);
const removed = await DB.delete('notes', { createdAt: { $lt: cutoff } });
if (removed > 0) {
Log.info('Old notes removed', { removed });
}
} finally {
await Core.releaseLock(lockId);
}
}
// Idempotent, so it can be called both at start and on enable.
function startJobs() {
if (cleanupTimer) {
return;
}
cleanupTimer = setInterval(() => {
removeOldNotes().catch((error) => Log.error('Cleanup failed', { error: error.message }));
}, CLEANUP_EVERY_MS);
}
function stopJobs() {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
// onEnabled fires ONCE per installed version (install, update), not on every container
// start. A job started only there dies with the first container restart, so start it here too.
startJobs();
// One handler only: a second Core.onEnabled call replaces the first.
Core.onEnabled(async () => {
await DB.createIndex('notes', { createdAt: -1 });
startJobs();
Log.info('acme/notes enabled', { instanceId: Core.getInstanceId() });
});
Core.onDisabled(async () => {
stopJobs();
});
module.exports = { removeOldNotes };
Core.onEnabledruns once per installed version (install and each update), not when a container restarts or wakes up. Use it for one-off setup (indexes, data migrations of the new version) and start background work at top level as well.Core.onEnabledandCore.onDisabledkeep one handler each: a second call replaces the first. Register each once and call your setup functions (likecreateNoteIndexesfrom Database) from it.Core.onDisabledruns when the instance is disabled or before an update replaces the container: stop timers there.Core.getLock(lockId, ttlSeconds)returns the id ornullwhen the lock is taken; locks are per instance. TTL is in seconds.- Identity at runtime:
Core.getInstanceId(),Core.getCompanyId(),Core.getModuleInfo()({ instanceId, companyId, code, version }),await Core.getCompany()for the company profile includingtimezone.
OVE page: datatable and modal
Register the view file:
'use strict';
const path = require('path');
const { Log, Pages } = require('@orbito/api-lib');
// The whole set is sent at once and replaces the previous one: a page left out is removed.
Pages.registerPages({
'/notes': {
name: 'Notes',
view: path.join(__dirname, 'pages', 'notes.yaml'),
placement: { location: 'navbar', icon: 'notes', order: 50 },
},
}).catch((error) => Log.error('Page registration failed', { error: error.message }));
pages/notes.yaml lists notes, searches, sorts, deletes a row after a confirmation and creates a note in a modal. It calls createNote and deleteNote from Method with params and validation and listNotes from Database:
type: flex
direction: column
gap: 3
childs:
- type: datatable
id: notesTable
title: Notes
perPage: 25
rowKey: _id
queryKey: query
searchPlaceholder: Search notes
emptyMessage: No notes yet
dataSource:
module: acme/notes
method: listNotes
sortParam: sortBy
sortDirParam: sortDir
actions:
- label: New note
icon: plus
variant: primary
action: openModal
target: noteModal
columns:
- header: Title
field: title
sortable: true
- header: Priority
field: priority
width: 1%
align: end
sortable: true
- header: Created
width: 160px
sortable: true
sortKey: createdAt
content:
- type: text
content: '{{ formatDate notesTable.$.createdAt "YYYY-MM-DD HH:mm" }}'
muted: true
- header: ''
width: 1%
content:
- type: button
icon: trash
title: Delete
variant: danger
outline: true
size: sm
onClick:
action: confirm
type: alert
title: Delete this note?
description: '{{ notesTable.$.title }}'
yesLabel: Delete
noLabel: Cancel
onYes:
action: callMethod
module: acme/notes
method: deleteNote
params:
noteId: '{{ notesTable.$._id }}'
refresh: [notesTable]
onError:
action: toast
type: alert
title: Could not delete the note
content: '{{ $error.message }}'
- type: modal
id: noteModal
title: New note
content:
type: flex
direction: column
gap: 2
childs:
- type: input
id: noteTitleInput
name: title
label: Title
- type: textinput
id: noteBodyInput
name: body
label: Text
rows: 4
footer:
type: flex
direction: row
justify: end
gap: 2
childs:
- type: button
label: Cancel
variant: secondary
outline: true
onClick:
action: closeModal
target: noteModal
- type: button
label: Save
variant: primary
onClick:
action: callMethod
module: acme/notes
method: createNote
withData:
- { from: noteTitleInput, as: title }
- { from: noteBodyInput, as: body }
refresh: [notesTable]
onSuccess:
- action: closeModal
target: noteModal
- action: toast
type: success
content: 'Note {{ $response.0._id }} saved'
onError:
- action: toast
type: alert
title: Could not save the note
content: '{{ $error.message }}'
- The datatable calls
dataSource.methodwithpage,perPage, the search text underqueryKey(query) and the sort undersortParam/sortDirParam(defaultssort/sortDir, valuesasc/desc). It accepts{ data, total }or a bare array; setresponsePathandtotalPathfor other shapes. The envelope is unwrapped by the table itself. moduleis required indataSourceand in everycallMethod.- In
onSuccessthe method result is$response.0(the envelope); inonErrorthe message is$error.message. withDatareads controls by theiridand sends them underas:{ from: noteTitleInput, as: title }sends{ title: 'Buy milk' }.paramswith{{ }}templates works too; templates always produce strings.- Inside a table cell,
{{ notesTable.$.field }}is the current row. refresh: [notesTable]reloads the table after success.placementadds a link:{ location: 'navbar', icon, order }or{ location: 'settings', section, icon, order }(sectionis a key registered withPages.registerSettingsSections). Icons are Tabler names withoutti-.nameis a plain string. A path may have parameters (/note/:noteId), read in the view as{{ $page.noteId }}.
More view types and actions in Polish: OVE views, actions, datatable. The smallest working page is in the example module.
Logging
const { Log } = require('@orbito/api-lib');
Log.info('Order imported', { orderId: 'A-1001', items: 3 });
Log.error('Import failed', { orderId: 'A-1001', error: 'HTTP 503' });
Log.debug | info | warn | error(message, meta) never throws and does not wait. Read the entries with omm logs --json; anything printed to stdout/stderr (console.log, crashes) is in omm logs --container --json. Keep debug quiet in loops: logs are stored per instance and day.
