Aquargin Way / Master rtext / Module R4

The ctx API

Every script on a custom page is handed one object: ctx. It is the page runtime — data, queries, record writes, reactivity, formatting and the little finishing touches — all behind one tidy handle. Learn ctx and you can make a page do almost anything.

6 units ~24 min Earn the ctx Adept stamp
Unit 1 of 6

Reading & writing data

ctx.get reads a value, ctx.set writes one — and both work against pageData, which is layered on top of the record.

A page carries two pools of data. The record is whatever record the page opened with — read-only, the source of truth. pageData is the page's own scratch space: the values your scripts and bindings work with. The two are merged into ctx.data, with pageData taking precedence, so the moment you ctx.set a path you have "covered" any field of the same name underneath.

  • ctx.get('order.total') — read a value by dotted path.
  • ctx.set('order.total', n) — write a value. This triggers a form re-bind, so any {|order.total|} placeholder and any bound input updates to match.
  • ctx.data — the merged view: {…record, …pageData}.
  • ctx.pageData — the page variables only (no record fields).
  • ctx.record — the current record, read-only (e.g. ctx.record._id).
read & write
// read a value (from pageData, falling back to the record)
const total = ctx.get('order.total');

// write a value — the UI re-binds so it follows along
ctx.set('order.total', total + 10);

// the current record is read-only — handy for its id
const recordId = ctx.record._id;

// the merged view vs. just the page variables
console.log(ctx.data);      // {…record, …pageData}
console.log(ctx.pageData);  // page variables only
pageData wins the merge

ctx.data is {…record, …pageData} — record first, pageData spread on top. So if a field status exists on the record and you've ctx.set('status', …) on the page, ctx.get('status') gives you the pageData value. Read from ctx.record when you specifically want the untouched record value (it never changes underneath you).

Unit 2 of 6

Querying records

Need data from another object? ctx.query fetches it for you — no fetch, no URLs, no auth headers to remember.

The simple form takes four arguments: the object API name, an equality map of field → value, the list of fields to return, and a limit.

simple query
// invoices that are Pending — name + amount only, up to 50
const pending = await ctx.query(
  'invoice__m',
  { status__m: 'Pending' },
  ['name', 'amount__m'],
  50
);

When equality isn't enough, pass an array of conditions instead of the map. Each condition is { field, condition, value }, and the condition is a short operator code:

  • e — equals
  • n — not-equals
  • c — contains
  • s — starts-with
  • g — greater than
  • l — less than
advanced query
// big-ticket invoices — condition array + security flag
const bigDeals = await ctx.query(
  'invoice__m',
  [{ field: 'amount__m', condition: 'g', value: 10000 }],
  ['name', 'amount__m'],
  50,
  true   // security: apply FLS + record-level access
);

// who's running this page?
const user = await ctx.getUser();
console.log(user.name, user.roleName, user.profileName);

That fifth argument is the security flag. Pass true and the query runs with field-level security and record-level access applied — the user only ever sees fields and records they're allowed to. And await ctx.getUser() returns the full current user, including name, roleName and profileName.

Ask only for the fields you'll actually show. A scoped field list makes the query cheaper to run, smaller to ship, and far easier to read six months from now than a grab-everything fetch.

Unit 3 of 6

Create, update, delete

Write records straight from the page — and they go through the very same pipeline as a form save.

Three methods cover writes, each await-ed:

  • await ctx.createRecord('invoice__m', {…}) — insert a new record.
  • await ctx.updateRecord('invoice__m', id, {status__m:'Paid'}) — patch fields on an existing record.
  • await ctx.deleteRecord('invoice__m', id) — remove a record.

This is the important part: these are not raw database pokes. Each one runs the full save pipeline — triggers fire, validation rules are enforced, formula fields recompute, and record history is recorded — exactly as if a user had saved the record from a form.

create / update / delete
// create a new invoice
const created = await ctx.createRecord('invoice__m', {
  name: 'INV-2041',
  amount__m: 1499,
  status__m: 'Pending'
});

// mark it paid
await ctx.updateRecord('invoice__m', created._id, { status__m: 'Paid' });

// delete — but ask first, then refresh the list
if (await ctx.confirm('Delete this invoice?')) {
  await ctx.deleteRecord('invoice__m', created._id);
  ctx.set('invoices', await ctx.query('invoice__m', {}, ['name', 'amount__m'], 50));
}
Your rules protect page writes too

Because the full pipeline runs, every trigger and validation rule you've built guards a page write just as it guards a form write. A required field, a "no negative amounts" rule, a post-save notification — they all apply. You don't get a faster, rule-free back door by writing from a page, and that's exactly what you want.

Unit 4 of 6

Reacting with watch

Derive values automatically: watch a dependency, ctx.set the result, and the placeholder refreshes itself. No DOM wrangling required.

A watcher fires its callback whenever the path it's watching changes:

  • ctx.watch('order.items', items => …) — watch a single path.
  • ctx.watchAll(['order.qty','order.price'], vals => …) — watch several at once; the callback receives the current values.

The classic use is a computed total. You never recompute by hand and you never touch an element — you just set the result and let binding repaint it.

computed total
// whenever the items change, recompute the order total
ctx.watch('order.items', items => {
  const total = items.reduce((sum, it) => sum + it.price * it.qty, 0);
  ctx.set('order.total', total);   // {|order.total|} refreshes on its own
});

// watch several paths together
ctx.watchAll(['order.qty', 'order.price'], ([qty, price]) => {
  ctx.set('order.line', qty * price);
});

Watchers auto-cleanup when the page is destroyed — you don't register and then have to remember to tear them down.

Don't fight the reactivity

Don't recompute a derived value manually on every input event, and definitely don't run a ctx.setInterval to poll your own page data. That's precisely the job watch exists for — declare the dependency once and let it fire when, and only when, the value changes.

Unit 5 of 6

Dates & formatting

MongoDB dates and locale-aware formatting are each one call. Reach for ctx.parseDate and ctx.format — never hand-rolled parsing.

ctx.parseDate

Turns a stored date value into a display string. The default mode is 'date', giving something like "Apr 13, 2026". Other modes: 'datetime', 'time', 'input-date' (for <input type="date"> fields), and 'iso'. Crucially, it understands the MongoDB {$date:{$numberlong:…}} envelope that raw values arrive wrapped in, so you never have to unwrap it yourself.

ctx.format

Formats a number for display — currency, plain number, or percent — with an options object.

parseDate & format
// dates — handles the {$date:{$numberLong:…}} envelope for you
ctx.parseDate(invoice.due__m);                 // "Apr 13, 2026"   (default 'date')
ctx.parseDate(invoice.due__m, 'datetime');   // date + time
ctx.parseDate(invoice.due__m, 'input-date'); // for an <input type="date">

// numbers — currency, plain number, percent
ctx.format(1234.5, 'currency', { currency: 'USD' });  // $1,234.50
ctx.format(1234.5, 'number', { decimals: 0 });        // 1,235
ctx.format(0.0825, 'percent');                       // 8.25%
Never hand-parse a date

Don't crack open the {$date:{$numberlong:…}} envelope yourself, and don't reach for new Date().toLocaleString(). Use ctx.parseDate for dates and ctx.format for numbers — they handle the envelope, the locale and the edge cases consistently, so every page on the platform reads the same way.

Unit 6 of 6

Feedback & utilities

The small tools that make a page feel finished — toasts, confirms, a loading veil, debouncing, timers, storage and cleanup.

  • ctx.toast(msg, 'success') — flash a message; also 'error', 'warning', 'info'. ctx.notify is an alias.
  • await ctx.confirm('Delete?') — a yes/no dialog that resolves to a boolean.
  • ctx.loading(true) / ctx.loading(false) — show or hide the loading overlay around slow work.
  • ctx.debounce(fn, 300) — wrap a handler so it only fires after activity settles; perfect for search inputs.
  • ctx.setInterval(fn, 30000) and ctx.setTimeout(fn, ms) — timers that are auto-cleared on destroy (or clear them yourself).
  • ctx.storage('key', value) writes, ctx.storage('key') reads — org-scoped and persists across navigations.
  • ctx.onDestroy(fn) — register a cleanup callback for things the framework can't tidy for you (a websocket, a third-party widget).
debounced search
// a search box that queries only after the user stops typing
const search = ctx.debounce(async term => {
  ctx.loading(true);
  const rows = await ctx.query(
    'invoice__m',
    [{ field: 'name', condition: 'c', value: term }],
    ['name', 'amount__m'],
    25
  );
  ctx.set('results', rows);
  ctx.loading(false);
}, 300);

// remember the user's last search across navigations (org-scoped)
ctx.storage('lastSearch', ctx.get('searchTerm'));
toast + confirm
async function archive(id) {
  if (!await ctx.confirm('Archive this invoice?')) return;
  await ctx.updateRecord('invoice__m', id, { status__m: 'Archived' });
  ctx.toast('Invoice archived', 'success');
}

// clean up anything the framework can't, on page destroy
ctx.onDestroy(() => socket.close());
That's the runtime — what's next

You can now read, query, write, react, format and finish a page entirely through ctx. Module R5 — Events, Navigation & Charts picks up from here: wiring user events, moving between pages and records, and rendering charts. And the full surface of every built-in lives in the Function Reference, with terms defined in the Glossary. Pass the checkpoint to claim your ctx Adept stamp.

Checkpoint

Earn the ctx Adept stamp

Six questions to lock in Module R4.