Skip to content

Database Workflows (PLV8)

For creating workflows, the visual Design mode, triggers, and security configuration, see Database Workflows in the User Guide.

Database Workflows written in AI/Code mode run as PLV8 functions - synchronous JavaScript executing inside PostgreSQL. They share a transaction with the calling context, have direct access to platform data via the api object, and complete in milliseconds. They cannot make HTTP calls, use AI APIs, or do any I/O outside the database. For that, use a Server Function instead.


Every PLV8 function follows a four-section layout: METADATA, CONSTANTS, PRIVATE_FUNCTIONS, and HANDLERS. When a new AI/Code workflow is created in the platform, the editor is pre-populated with a working template following this structure.


  • When the caller includes a cmd field, the platform invokes the handler with that name.
  • When cmd is omitted, the run handler is called.
  • Each handler receives input - the request payload with cmd removed.
  • Handler names use snake_case.
  • A single-function shorthand is supported: returning input => { ... } directly is treated as { run: fn }.

The __meta block tells the platform about this function’s identity and dependencies:

FieldDescription
thing_idStable UUID for this function - required
can_deleteWhether records created by this function can be deleted
function_targetsThing IDs of other functions this function calls via api.execute_function
view_targetsThing IDs of views this function reads via api.get_data
entity_targetsThing IDs of entities this function reads or writes
component_targetsThing IDs of components this function reads or writes

Registering targets is not strictly required for the function to run, but it allows the platform to track dependencies and helps keep the system stable over time. The AI assistant maintains this block automatically as you develop - you can also ask it explicitly: “update the meta object to reflect all targets in this code.”


The AI/Code tab opens in chat mode by default. Describe what the function should do and the AI generates PLV8 code following the structure above. Click Apply to push a suggestion into the editor - suggestions are never applied automatically.

Before prompting, add the entities, views, and functions the AI should work with via the Context picker. The AI does not auto-discover your data model - without context it cannot reference the correct fields, view parameters, or function signatures. The more relevant context you add, the more accurate the generated code will be.

Use See Code to switch to the editor and review or modify the code directly. Switch back to Use AI at any time to continue iterating.

The AI is aware of PLV8 constraints: it will not generate fetch, setTimeout, or any other Node.js/browser API. If a task genuinely requires those, it will suggest using a Server Function instead.


api is injected automatically - no import needed. Call methods directly, without await (PLV8 is synchronous):

const rows = api.get_data({
target: view_thing_id,
params: { status: api.constants.statuses.live }
});

Raw SQL is not allowed in PLV8 functions. Do not use plv8.execute, SELECT, INSERT, UPDATE, or DELETE directly - all data access goes through api.


Load rows from a view or dataset.

FieldRequiredDescription
targetyesView or dataset thing ID
paramsnoFilter/input values for the view (default {})
options.page_infono{ page_size, page, skip, order_by } - defaults: page_size 50, page 1

Returns an array of rows. Throws on failure.

const rows = api.get_data({
target: constants.open_orders_view,
params: { customer_id: input.customer_id },
options: { page_info: { page_size: 100 } }
});

Query an entity or component table directly with SQL fragments.

FieldRequiredDefaultDescription
targetyesEntity or component thing ID
select_columnsnoallComma-separated column list
filternononeSQL WHERE fragment (no WHERE keyword)
order_bynononeSQL ORDER BY fragment
offsetnononeSkip rows
limitnono limitMax rows
all_versionsnofalseInclude all versions

Always use api.quote_literal for dynamic values in filter:

const lines = api.query_data({
target: constants.invoice_lines_component,
filter: `parent_thing_id = ` + api.quote_literal(input.thing_id),
order_by: `ord asc`
});

Save one or more records as draft (no submit).

api.save_data({
target: constants.invoice_entity,
item: { thing_id: input.thing_id, version_id: input.version_id, total: new_total }
});
api.save_data({
target: constants.invoice_lines_component,
items: line_rows,
options: { run_under_transaction: true }
});

Save and submit in one step. Same arguments as api.save_data.

api.save_and_submit({
target: constants.invoice_entity,
item: input
});

Submit an existing record version without saving field changes.

api.submit({
target: constants.invoice_entity,
item: { thing_id: input.thing_id, version_id: input.version_id }
});

Delete one or more records. Provide item or items, not both.

api.delete_data({
target: constants.invoice_lines_component,
items: stale_lines,
options: { run_under_transaction: true }
});

Clone an entity record into a new instance. Uses source instead of target.

const cloned = api.clone_data({
source: { thing_id: input.thing_id, version_id: input.version_id },
item: template_record
});

Create a new draft version from an existing instance. Uses source only (no item).

const draft = api.clone_version({
source: { thing_id: input.thing_id, version_id: input.version_id }
});
// draft.thing_id, draft.version_id are the new version's IDs

Call another published function or workflow.

FieldRequiredDescription
targetyesFunction thing ID
inputyesInput object (use {} if empty)
cmdnoHandler name - defaults to run
const result = api.execute_function({
target: constants.recalculate_totals_fn,
cmd: `recalculate`,
input: { thing_id: input.thing_id, version_id: input.version_id }
});

Return field-level read/write access for an entity.

const structure = api.describe_structure({ target: constants.invoice_entity });
const can_edit = structure.property_access.find(p => p.property_id === `total`).can_write;

Recalculate derived fields on a record and return the updated version.

const updated = api.recalculate_data({
target: constants.invoice_entity,
item: invoice_record
});
Object.assign(invoice_record, updated);

MemberKindDescription
applicationpropertyCurrent application object
user()functionCurrent user, or { anonymous: true }
device_id()functionDevice ID for the session
client_environment()functionClient environment (features, settings)
const user = api.current.user();
if (user.anonymous) {
throw new Error(`Authentication required`);
}
if (api.is_feature_enabled(`my_feature`)) {
// ...
}

Read-only reference values. Do not duplicate these locally.

// statuses: draft (1), submitted (2), live (3), history (4), deleted (5)
filter: `status = ` + api.constants.statuses.live
// entity_types: versioned, simple, system
if (entity.entity_type__thing_id === api.constants.entity_types.versioned) { ... }

Safely embed a dynamic value in a query_data filter or order fragment.

filter: `thing_id = ` + api.quote_literal(input.thing_id)
+ ` AND status = ` + api.quote_literal(api.constants.statuses.live)

Never concatenate raw input directly into filter strings.


Write a function log row.

MethodLevel
api.log({ log_level?, message?, data? })configurable
api.log_debug(message?, data?)debug
api.log_info(message?, data?)info
api.log_warn(message?, data?)warn
api.log_error(message?, data?)error
api.log_info(`Invoice submitted`, { thing_id: input.thing_id });
try {
api.save_data({ target: constants.invoice_entity, item: record });
} catch (err) {
api.log_error(`Save failed`, { message: err.message });
throw err;
}

Generate a random UUID string.

const id = api.new_uuid();

Run a callback in a nested transaction. Rolls back only that block on throw, without affecting the outer transaction unless you rethrow.

api.subtransaction(function () {
api.save_data({ target: constants.invoice_entity, item: header });
api.save_data({ target: constants.invoice_lines_component, items: lines });
});

Serialize execution for a shared resource using a session lock.

const result = api.run_with_mutex(`stock_` + warehouse_id, function () {
const stock = api.get_data({ target: constants.stock_view, params: { warehouse_id } });
return api.save_data({
target: constants.stock_entity,
item: { ...stock[0], quantity: stock[0].quantity - input.quantity }
});
});

Acquire a transaction-scoped lock. Held until the transaction commits or rolls back.

api.guard_transaction(`order_` + input.order_id, 15);
// proceed with saves - no other transaction can enter with the same key
run_with_mutexguard_transaction
Lock scopeOne handler callEntire current transaction
Typical useShort critical sectionMulti-step save across a transaction

const hex = api.md5(`some_text`);
const deterministic_id = api.md5_uuid(`prefix_` + counter);

Deep-copy an object before mutating it.

const copy = api.clone(original_record);
copy.total = new_total;
api.save_data({ target: constants.invoice_entity, item: copy });

Elapsed time from the current transaction start, useful for profiling.

const start = api.laptime();
// ... work ...
const elapsed = api.laptime();

KindExample
Handlersubmit_invoice, recalculate_totals
Entity constantinvoice_entity
Component constantinvoice_lines_component
Function constantsubmit_invoice_fn
View constantinvoice_status_view
Record variableinvoice_record
Record arrayinvoice_records

  • Keep handlers imperative - avoid classes, repositories, or service layers.
  • Validate input early; throw clear errors.
  • Always include a run handler as the default entry point.
  • All data access goes through api - no raw SQL.
  • Use api.quote_literal for every dynamic value in query_data filters.
  • Keep logic short and transactional. If the work may take time or needs external I/O, use an Execute Server Function step instead.