Skip to content

Writing Functions

Every Cloud Code function is a single JavaScript module that exports a handler. AppAmbit calls your handler with one argument — the context object, ctx — and treats whatever you return as the result.

index.js
export async function handler(ctx) {
  // your logic here
  return { ok: true }; // any JSON-serializable value becomes the output
}
  • Your handler can be async or synchronous — both work.
  • You can write it as a named export function handler or as export default.
  • The return value is captured as the invocation's output. Return plain objects, arrays, strings, or numbers — anything that serializes to JSON.

Keep it small and focused

A function should do one thing. If you find yourself branching heavily on ctx.req, that's usually a sign you want two functions instead of one.


The ctx object

ctx is how your function receives its input and reaches everything it's allowed to touch.

Property What it holds
ctx.req The input for manual and HTTP runs — the payload you passed, or the incoming HTTP request. undefined for event-triggered runs.
ctx.event The input for event runs — details of the data change that fired the function. undefined otherwise.
ctx.consumer { id } of the app user who called an HTTP-triggered function, or null. Context only — see the note below.
ctx.env Your plain environment variables, as a plain object.
ctx.secrets Your secret environment variables, decrypted for this run.
ctx.log(...args) Writes a line to the invocation's logs.
ctx.appambit The built-in SDK for your database, CMS, and push. See The data SDK.

ctx.req and ctx.event are mutually exclusive

A run is either request-driven (ctx.req is set) or event-driven (ctx.event is set) — never both. Read whichever one applies to your trigger, and default the other:

const input = ctx.req ?? ctx.event ?? {};

ctx.consumer is context, not a permission check

When an app user calls your HTTP function, ctx.consumer.id tells you who called — handy for logging or looking up their data. It does not restrict what your function can do: every function runs with your app's full scope. If a user should only see their own rows, enforce that yourself in your query.


Logging

Use ctx.log(...) to record what your function is doing. Logs are captured per invocation and shown alongside the result, which makes them your main debugging tool.

export async function handler(ctx) {
  ctx.log("starting", { input: ctx.req });
  const total = 1 + 2;
  ctx.log(`computed total = ${total}`);
  return { total };
}

Logs are kept as a bounded tail (the most recent lines), so log what matters rather than everything.


The data SDK

ctx.appambit is how your function reads and writes data — all scoped to your app automatically. You never handle keys or connection strings.

Method What it does
ctx.appambit.db(sql, params) Run one SQL statement against your app database.
ctx.appambit.batch(statements, transaction) Run several statements, optionally in a transaction.
ctx.appambit.cms.list(collection, query) List CMS entries.
ctx.appambit.cms.get(collection, uuid) Fetch a single CMS entry.
ctx.appambit.cms.publish(collection, data, status) Create and publish a CMS entry.
ctx.appambit.push(payload) Send a push notification.

Every method is await-able and returns the parsed result. If a call fails, it throws an Error — see Handling errors.

Querying the database

db runs one SQL statement. Always pass user-supplied values through params (using ? placeholders) rather than string-concatenating them.

const res = await ctx.appambit.db(
  "SELECT id, name FROM users WHERE active = ?",
  [1]
);

The result looks like this:

{
  "results": [
    {
      "columns": ["id", "name"],
      "rows": [[1, "Alice"], [2, "Bob"]],
      "rows_read": 2,
      "rows_written": 0
    }
  ],
  "request_id": "…"
}

Rows are positional arrays

Each row is an array of values, in the same order as columns — not an object keyed by column name. Read a cell by its position, or map it yourself:

const { columns, rows } = res.results[0];
const users = rows.map((row) =>
  Object.fromEntries(columns.map((col, i) => [col, row[i]]))
);
// users -> [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]

Writes report how many rows changed via rows_written:

const res = await ctx.appambit.db(
  "INSERT INTO favorites (note, created_at) VALUES (?, ?)",
  ["hello", new Date().toISOString()]
);
ctx.log(`wrote ${res.results[0].rows_written} row(s)`);

Running several statements at once

Use batch when you have multiple statements. Pass true as the second argument to wrap them in a transaction — if any statement fails, none are applied.

await ctx.appambit.batch(
  [
    { sql: "INSERT INTO users (name, email) VALUES (?, ?)", params: ["Ada", "ada@example.com"] },
    { sql: "UPDATE stats SET user_count = user_count + 1" },
  ],
  true // run in a transaction
);

The result has the same shape as db, with one entry in results per statement.

Write your SQL for SQLite

Your app database uses SQLite-compatible syntax. See the Database guide for the SQL dialect and the statements that are allowed.

Reading and publishing CMS content

// List entries (query supports the same shape as the CMS API)
const list = await ctx.appambit.cms.list("posts", { limit: 5 });
ctx.log(`found ${list.data.length} posts`);

// Fetch one entry by its UUID
const one = await ctx.appambit.cms.get("posts", "550e8400-e29b-41d4-a716-446655440000");

// Create and publish a new entry
const created = await ctx.appambit.cms.publish("posts", {
  title: "Posted from Cloud Code",
  body: "Generated automatically.",
});

list returns { data, meta, request_id }, where meta carries pagination (current_page, per_page, total, last_page). get returns { data, request_id }. When the UUID does not exist (or is not published), get throws — like every other data call — with err.code cms_error and err.status 404; handle it as shown in Handling errors below.

Each CMS entry carries your content type's own fields at the top level (not nested), alongside the system fields the CMS manages. Its id is the entry's UUID, as a string:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  // ...your content type's fields, at the top level:
  "title": "10 Laravel Tips",
  "slug": "10-laravel-tips",
  "content": "<p>…</p>",
  "category": "tech",
  // ...plus the system-managed timestamps:
  "published_at": "2025-01-20T15:30:00+00:00",
  "created_at": "2025-01-19T10:00:00+00:00",
  "updated_at": "2025-01-20T15:30:00+00:00"
}

Same shape as the CMS API

This is the exact entry shape returned by the CMS REST API — access your fields directly (entry.title), not under a data wrapper.

Sending a push notification

await ctx.appambit.push({
  title: "Order shipped",
  body: "Your order is on its way!",
});

push sends on behalf of your app and returns { result, request_id }.


Handling errors

When a data call fails, it throws an Error carrying two useful properties:

  • err.code — a short machine-readable code (for example forbidden_query, quota_exceeded, cms_error).
  • err.status — the underlying HTTP status.

Wrap calls that might fail and decide what to return:

export async function handler(ctx) {
  try {
    const res = await ctx.appambit.db("SELECT count(*) FROM orders");
    return { count: res.results[0].rows[0][0] };
  } catch (err) {
    ctx.log(`query failed: ${err.code} (${err.status})`);
    return { ok: false, error: err.code };
  }
}

See the full list of codes in Configuration & Limits → Error reference.


What's next

  • Triggers


    Decide how your function runs: manually, over HTTP, or on data changes.

    View Guide ➔

  • Examples & Tips


    Complete functions that read, write, and notify — plus tips for reliability.

    View Guide ➔