Orbito - Dokumentacja modułówOrbito - Dokumentacja modułów
Wprowadzenie
Pierwsze kroki
API
Komunikacja
Widoki
Wdrożenie
Społeczność
For AI agents
Wprowadzenie
Pierwsze kroki
API
Komunikacja
Widoki
Wdrożenie
Społeczność
For AI agents
  • For AI agents

    • Orbito for AI agents
    • Module anatomy
    • The build loop
    • Cheatsheet
    • Example module acme/hello
    • Company automations
    • Changelog

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 same ctx is bound as this. params is the one object the caller sent (the REST body, callMethod params in OVE, --params of omm call).
  • ctx.userId is the user who triggered the call, ctx.permissions their permissions in this module (short codes, '*' for owners and admins), ctx.caller the calling module instance ({ instanceId, moduleCode, instanceName }) or null when the core called (user, automation), ctx.origin the automation run or null.
  • params, returns and throws are 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: ObjectId becomes a hex string, Date an ISO string. A thrown Error reaches 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 call unwraps 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 call Utils.loadMethods(path.join(__dirname, 'methods')). Subdirectories become dotted names: methods/jobs/claim.js is jobs.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 as this only on a regular function; 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 with err.code === 'AMBIGUOUS_MODULE_TARGET' when the company has several instances of the target, then use Core.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 companyId to documents or indexes.
  • DB.Schema validates with fastest-validator on create only (fields are required unless optional: true), applies defaults and casts query values to the field types. update does not validate.
  • find(query, limit = 100, offset = 0, sort = {}) returns plain objects, never more than limit.
  • 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 with new DB.ObjectId(hex).
  • The listNotes handler returns { data, total } and reads query, 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 in DisplayFormat (SECRET, URL, EMAIL, TEXT, MARKDOWN, POSITIVE_INTEGER, DATE_ONLY and more).
  • title, name and description are a string or a language map; title.en is required.
  • validate(params, raport) runs before the core saves; one raport.addError(field, message) rejects the save.
  • An action handler gets the saved values and returns { success, message, needReload } (all optional). A thrown error becomes success: false with 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.
  • problemKey deduplicates per instance: a repeated report updates the entry and puts it back to waiting; resolve deletes it (a missing key is a no-op). Never use a random key.
  • desc is a string or { en, pl }; link is { module, path } of one of your pages; entityName labels 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 }, object matching ^[a-z][a-z0-9_-]*$, id a string) say what the event is about; History.list filters by the full triple.
  • History.add never throws (returns null on failure); History.list throws 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 __instanceId to every element's params and strips it before your handlers run; declaring a param with that name throws at registration. enable, disable and clear are all required.
  • params uses the automation schema (Automation.PropertyType, Automation.DisplayFormat, options.values for a fixed list), not fastest-validator. Only graphs saved by omm are checked against it, the editor in the panel saves anything: check the values you use in the handler.
  • execute returns the branch name from outputs ('true', 'false', your own); without outputs the run goes on next whatever it returns. A thrown error ends the run as errored.
  • An action that has to wait calls Automation.waitAndRetry(seconds) (run the action again later; keep execute idempotent) or Automation.delay(seconds) (continue on next later).
  • 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.onEnabled runs 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.onEnabled and Core.onDisabled keep one handler each: a second call replaces the first. Register each once and call your setup functions (like createNoteIndexes from Database) from it.
  • Core.onDisabled runs when the instance is disabled or before an update replaces the container: stop timers there.
  • Core.getLock(lockId, ttlSeconds) returns the id or null when 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 including timezone.

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.method with page, perPage, the search text under queryKey (query) and the sort under sortParam / sortDirParam (defaults sort / sortDir, values asc / desc). It accepts { data, total } or a bare array; set responsePath and totalPath for other shapes. The envelope is unwrapped by the table itself.
  • module is required in dataSource and in every callMethod.
  • In onSuccess the method result is $response.0 (the envelope); in onError the message is $error.message.
  • withData reads controls by their id and sends them under as: { from: noteTitleInput, as: title } sends { title: 'Buy milk' }. params with {{ }} templates works too; templates always produce strings.
  • Inside a table cell, {{ notesTable.$.field }} is the current row.
  • refresh: [notesTable] reloads the table after success.
  • placement adds a link: { location: 'navbar', icon, order } or { location: 'settings', section, icon, order } (section is a key registered with Pages.registerSettingsSections). Icons are Tabler names without ti-. name is 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.

Ostatnio zaktualizowano:: 10/14/26, 10:48 AM
Contributors: Wiktor Koźmiński, Claude Fable 5.1
Prev
The build loop
Next
Example module acme/hello