Skip to content
Talk to our solutions team

Hooks and the execution pipeline

Every extension point in the Data API is a hook on a five-phase chain: built-in behaviour, schema-declared pointcuts: and triggers:, access rules, computed fields, stateflow guards. They differ only in the phase and priority they occupy. Nothing runs outside the chain.

PhaseRunsFires onStill able to change
BeforeValidateBefore any validation, before compilationevery operationthe payload, the query AST, the mutation AST
AfterValidateAfter validation, before compilationevery operationthe payload, the query AST, the mutation AST
BeforeExecAfter the AST is compiled to SQL, before the statement runsevery operationnothing that reaches the SQL — the statement is already built
AfterExecAfter a mutation statement returnscreate, update, deletethe returned rows, response metadata
AfterScanAfter rows are scanned and relations eager-loadedread, and named actions returning rowsthe returned rows

AfterExec never fires on a read. AfterScan never fires on an entity create, update or delete — not even one with RETURNING. The mutation path scans the returned rows and goes straight to AfterExec, so read-side hooks (masking, decryption, virtual computed fields) do not touch the row a POST or PUT echoes back.

Operations are a bitmask — create, read, update, delete — and a hook runs when any bit overlaps with the current operation.

#StageWhat it may change
1Drain gate — a closed engine refuses with exec: engine closed
2Observer timer starts
3Per-request timeout wraps the context, when the request carries one
4Entity resolved from the compiled schema
5A remote entity short-circuits to the HTTP router and returnsskips every later stage
6AST built from the query, or from filters / select / order_by / limit / offset
7Row cap injected as a LIMIT — see the row capthe AST
8Hook context built: identity from the request principal, then asof / version / unmask / untokenize from the request flags
9BeforeValidatepayload, AST
10AfterValidatepayload, AST
11AST compiled to SQL; row-level security is injected as a WHERE predicate here
12BeforeExecnothing that reaches this statement
13Statement executed on the tenant’s pool
14Rows scanned into both row views
15Includes executed — one extra statement per relation, then children grouped onto parentsrows
16AfterScanrows
17Cursor metadata built

A write is dispatched by the entity’s history strategy before the standard path runs. The first matching branch wins:

OrderEntityPath
1materialization: remoteHTTP router, no SQL compiled
2Versioned history (type2, type6)update rewritten to close-current + insert-new; delete rewritten to close
3typeauditpre-image fetched, standard path, then one audit row per changed field
4type3pre-image fetched and folded into the payload as <field>_prev
5type4standard path, plus a pre-image copy into the history table
6everything elsestandard path

The standard path itself:

#StageWhat it may change
1Mutation AST built or accepted from the caller
2?purge threaded onto the mutation
3Payload extracted from the mutation data when not supplied separately
4Hook context built
5BeforeValidatepayload, mutation AST
6AfterValidatepayload, mutation AST
7Payload copied back into the mutation AST
8AST compiled to SQL
9BeforeExecnothing that reaches this statement
10Placeholders normalised for the dialect
11Statement executed — Query when the mutation has RETURNING, otherwise Execute
12Rows and affected-row count populated
13AfterExecreturned rows

Within a phase, hooks run in ascending Priority. Ties run in registration order, and registration order is fixed by the service’s boot sequence — so the order is deterministic across restarts.

BucketPriorityIntended for
Access100authorization decisions
Validation200input validation
Transform300value rewriting
Business400domain rules
Infrastructure500audit, events, cache

Any integer is legal, and values between buckets (250, 310) are normal. The supported range is [0, 1000].

This is the complete chain in the deployable service, in run order. Two rows are conditional; everything else is always present.

PriorityHookOperationsDoes
50v2.request_options_bridgeallCopies the query-string flags onto the hook context
100defaultscreateFills defaultvalue: on fields the caller omitted
100accessallTier-1 action guards, deny-by-default
130–150pointcut dispatchersper methodBeforeBulk (130), BeforeBulkInsert (140), the Before* CRUD methods (150)
200validatecreate, updateField validations: and modifiers
250entityvalidatecreate, updateEntity entityvalidations:
410pointcut dispatcherupdateBeforeTransition

defaults and access are both at 100; defaults was registered first, so field defaults are filled before the access guard runs.

PriorityHookOperationsDoes
150auditcreate, updateFills createdby / createdon / updatedby / updatedon
175temporalreadResolves ?version=latest-N against the row’s current version number
176temporal_type4readRedirects a point-in-time read to the history table
190rls_createcreateEvaluates the RLS create rule in-process. Skipped when row-level security is off
260filefieldcreate, updateStores uploaded file parts. Only when file storage is configured
330–335pointcut dispatchersper methodOnGuardFail, OnAccessDeny (330), OnRLSFilter (335)
350businessconstraintcreate, update, deleteMulti-entity constraints:
350stateflowcreate, updateState-machine transition legality
500triggercreate, update, deletebefore_create / before_update / before_delete triggers
PriorityHookOperationsDoes
100pointcut dispatcherallBeforeBegin
105preimageupdate, deleteFetches the pre-mutation row. Runs only for entities declaring an After* / On*Conflict pointcut, or named as the source of a cross-entity refresh trigger
110pointcut dispatcherreadBeforeCacheRead
290materializedcreate, updateEvaluates write-time computed fields into the payload — after the statement is compiled. See below
380–491pointcut dispatchersper methodBeforeFieldEncrypt (380), OnBulkChunk / BeforeSCDInsert / BeforeSCDExpire (470), BeforeCascade (480), BeforeIndex (490), BeforeReindex (491)
PriorityHookOperationsDoes
420–545pointcut dispatchersper methodAfterTransition (420), AfterBulk (460), AfterBulkInsert (470), the After* CRUD methods and AfterSCDClose (480), OnInsertConflict / OnUpdateConflict / AfterIndex (490), AfterCommit (500), OnRollback (510), OnTerminal (530), AfterCacheWrite (540), OnCacheInvalidate (545)
600eventcreate, update, deleteRegistered with no event bus — returns immediately. See below
650materialized_refresh_enqueuecreate, update, deleteQueues cross-entity computed-field refreshes. Only when the schema declares refresh triggers
PriorityHookOperationsDoes
260filefieldreadResolves stored file references to URLs. Only when file storage is configured
280computedreadEvaluates virtual computed fields onto scanned rows
405–480pointcut dispatchersreadBeforeFieldDecrypt (405), AfterFieldDecrypt (410), OnFieldMask (420), OnComplianceMask (430), AfterQuery / AfterAggregate (480)

These keys load without complaint, survive validation, and do nothing at runtime.

DeclarationStatus
after_create / after_update / after_delete triggersThe after-trigger hook is never constructed. The schema loads; the script never runs
beforeRead / afterRead / beforePurge / afterPurge in the legacy hooks: blockPermanently inert — the trigger hook subscribes only to create, update and delete, and generates no event name for reads or purges
events: on an entityThe hook is registered with no bus, so it returns before doing anything. No event is ever published
subscriptions: at schema or entity levelParsed and merged into the schema. No runtime code reads it — see Change streams
Field transforms:The transform hook (AfterValidate / 300) is not in the chain
Field-level encryptionNeither the write-side nor the read-side encryption hook is in the chain
Enum label resolution on readThe enum hook is not in the chain — see Enums
Read-side masking from compliances:The compliance hook is not in the chain — see Field protection
Response caching hooksNeither the read-side nor the write-side cache hook is in the chain — see Caching
Slowly-changing-dimension hookNot in the chain; versioning is handled by the history dispatch above instead
Audit-sink and metrics hooksNot in the chain; observability comes from the engine observer instead
The legacy string-filter row-security hookDeliberately not wired. Running it alongside the compiler’s predicate translator double-evaluates every rule in two incompatible formats
script-file: on any hook, pointcut, trigger or ruleFails at evaluation with file-referenced scripts not yet wired. Only inline bodies execute
tx-mode: on a scripted endpointParsed and validated; no code opens a transaction from it

pointcuts: is the declarative hook surface: a schema author names a lifecycle method and attaches a script. It is entity-scoped only — there is no top-level pointcuts: key, and a top-level block is dropped without a message.

entities:
- name: sales_order
pointcuts:
- method: BeforeInsert
name: stamp_channel
language: javascript
rule: |
({ __patch: { channel: ctx.user.roles.indexOf("api") >= 0 ? "api" : "web" } });
- method: AfterUpdate
name: notify_downstream
async: true
order: 10
code: ORDER_HOOK_FAILED
tags: [audit]
script:
language: javascript
expression: |
data.create("order_event", { order_id: ctx.row.id, kind: "updated" });
KeyTypeDefaultMeaning
methodstringCanonical method name. Case-sensitive; an unknown value fails the load
namestringLabel used in logs, metrics and error messages
groupstringLegacy v1 form, backfilled into method when method is absent
languagestringjavascriptEngine for the inline rule: shortcut
rulestringInline body, the shortcut form. Used only when script: is absent
scriptmappingNested script reference; wins over rule:
orderint0Tie-break among pointcuts on the same method; lower first
asyncboolfalseRun off the request path. Ignored on every Before* method and on OnInsertConflict / OnUpdateConflict / OnGuardFail
codestringError code surfaced when this pointcut aborts
tagslistObservability labels
enabledbooltrueExplicit false skips the pointcut at dispatch
MethodPhaseOperationsPriority
BeforeQueryBeforeValidateread150
AfterQueryAfterScanread480
BeforeAggregateBeforeValidateread150
AfterAggregateAfterScanread480
BeforeInsertBeforeValidatecreate150
AfterInsertAfterExeccreate480
OnInsertConflictAfterExeccreate490
BeforeBulkInsertBeforeValidatecreate140
AfterBulkInsertAfterExeccreate470
BeforeUpdateBeforeValidateupdate150
AfterUpdateAfterExecupdate480
OnUpdateConflictAfterExecupdate490
BeforeUpsertBeforeValidatecreate, update150
AfterUpsertAfterExeccreate, update480
BeforeDeleteBeforeValidatedelete150
AfterDeleteAfterExecdelete480
BeforeSoftDeleteBeforeValidatedelete150
AfterSoftDeleteAfterExecdelete480
BeforeCascadeBeforeExecdelete480
BeforePurgeBeforeValidatedelete150
AfterPurgeAfterExecdelete480
BeforeRestoreBeforeValidateupdate150
AfterRestoreAfterExecupdate480
BeforeBulkBeforeValidateall130
AfterBulkAfterExecall460
OnBulkChunkBeforeExecall470
BeforeBeginBeforeExecall100
AfterCommitAfterExecall500
OnRollbackAfterExecall510
BeforeTransitionBeforeValidateupdate410
AfterTransitionAfterExecupdate420
OnGuardFailAfterValidateupdate330
OnTerminalAfterExecupdate530
BeforeSCDInsertBeforeExeccreate470
BeforeSCDExpireBeforeExecupdate470
AfterSCDCloseAfterExecupdate480
BeforeIndexBeforeExecall490
AfterIndexAfterExecall490
BeforeReindexBeforeExecall491
BeforeFieldEncryptBeforeExeccreate, update380
AfterFieldDecryptAfterScanread410
BeforeFieldDecryptAfterScanread405
OnFieldMaskAfterScanread420
BeforeCacheReadBeforeExecread110
AfterCacheWriteAfterExecall540
OnCacheInvalidateAfterExecall545
OnAccessDenyAfterValidateall330
OnRLSFilterAfterValidateread335
OnComplianceMaskAfterScanread430

A dispatcher for every one of these is installed. The dispatcher fires at its phase whether or not the mechanism it is named after is active — OnComplianceMask runs at AfterScan on every read of an entity that declares it, regardless of whether any masking happened.

Script returnsEffect
null / nothingContinue
false from a Before* methodAbort with the pointcut’s code:, or POINTCUT_ABORT
{ __abort: true, code, message }Abort with that code and message
{ __patch: { field: value } } from a Before* methodShallow-merge into the payload
{ __replace: { … } } from OnInsertConflictStored on the hook context. Nothing reads it back today
Anything elseIgnored

After* methods plus OnAccessDeny, OnRLSFilter, OnComplianceMask, OnRollback, OnCacheInvalidate and OnTerminal are observers: a script crash there is recorded on the hook context and the operation proceeds. Every other method returns a typed error that aborts the request.

Do not put anything that must not silently fail in an After* pointcut.

async: true sends the work to a process-wide worker pool — 8 workers, a queue of 2048 — shared across tenants.

One binding, named ctx.

KeyPopulated on
ctx.opAlways — create / update / delete / query
ctx.entityAlways — the entity name
ctx.tenantAlways — the full customer:product:env:tenant key
ctx.user.id, ctx.user.rolesAlways
ctx.methodAlways — the pointcut method name
ctx.metadataAlways — the per-request bag shared across hooks
ctx.payloadWhenever a payload exists. A copy
ctx.rowAfter* and On*Conflict, single-row — the first scanned row
ctx.rowsBulk* methods, when rows were scanned
ctx.previousAfter* and On*Conflict on update and delete, from the pre-image hook
ctx.txWhen the operation is inside a transaction. Opaque to scripts

triggers: is the older declarative surface, and it uses a different binding vocabulary from pointcuts.

entities:
- name: sales_order
triggers:
- name: block_locked_orders
event: before_update
priority: 10
language: javascript
script: |
payload.status !== "locked";
KeyMeaning
nameLabel used in the abort message
eventbefore_create / before_update / before_delete — matched case-insensitively
priorityOrder among triggers on the same event; lower first
language, expression, script, script-file, functionThe script body, inline on the trigger — not nested under a script: mapping

Bindings: tenant, user_id, roles, entity, operation (Create / Read / Update / Delete, capitalized), payload on mutations, rows after a scan.

A before_* trigger that returns boolean false aborts with trigger "<name>": operation aborted by trigger. Any other return value is ignored.

js and javascript both select the JavaScript engine; the other accepted language: values are expr (the default when the key is empty), cel, lua, starlark, go and wasm. Anything else fails with script: unsupported runtime.

The engine is ECMAScript 5.1 with parts of newer editions. There is no module loader, no require, no fetch, and no console — logging goes through log.info(…).

One script engine is built per (tenant, datastore) and cached for the life of that tenant’s data engine. It holds the compiled-script cache, so compilation cost amortises across a tenant’s requests without sharing compiled code between tenants.

The value of the last expression evaluated is the result. For a guard, return a bool.

Guard results are coerced: nil and false are false, 0 and "" are false, and any non-empty string or non-zero number is true. A guard that accidentally returns the string "false" evaluates as allow. A value that cannot be coerced fails with script: expression did not return a boolean.

A panic inside a script is recovered and returned as an evaluation error, not a crash.

NamespaceFunctions
data.*query, find_one, find_by_id, count, exists, aggregate, create, create_many, update, update_many, delete, upsert
schema.*entity, fields, field, relations, stateflow, field_group — read-only, no database access
json.*, string.*, math.*, array.*, time.*, template.*, hash.*, log.*Pure-compute utilities
cpet.*customer, product, environment, tenant — identity strings, always present, empty when the host supplies no identity
Rootulid(), uuid(), nanoid(), now(), tsnano(), interpolate()
fs.*Host filesystem — read: readFile, readLines, readJSON, readDir, glob, readFileGlob; write: writeFile, writeLines, writeJSON, appendFile; move: copyFile, moveFile, rename, deleteFile; directories: mkdir, mkdirAll, removeDir, removeAll; inspect: exists, stat, isDir, isFile; temp: tempFile, tempDir; paths: abs, join, dir, base, ext; metadata: chmod, chown, symlink, readlink

A legacy flat surface is also registered — bare query, queryOne, queryById, count, exists, aggregate, insert, insertMany, update, updateMany, deleteRecord, upsert, getEntity, getFields, getField, getRelations, getStateflow, getFieldGroup, hash, hmac, randomUUID, randomBytes. Prefer the namespaced form: it cannot be shadowed by a tenant-shipped local.

A data.* call goes back through Engine.Query or Engine.Create, which means it runs the full hook chain again — access rules, row-level security, validation, audit stamping. A script that calls data.update on the entity it is hooked to will re-enter its own pointcuts.

No script gets the request context either. The evaluator discards the context it is handed and calls the interpreter directly, so no hook, pointcut, trigger, rule or guard body can be cancelled by the request, and none of them can read anything the request carries beyond the bindings listed above.

AfterInsert is an observer method, so a failure here is recorded and the insert still succeeds.

entities:
- name: sales_order
pointcuts:
- method: AfterInsert
name: high_value_approval
script:
language: javascript
script: |
if (ctx.row.total_amount > 100000) {
data.create("approval_task", {
id: ulid(),
entity: "sales_order",
ref_id: ctx.row.id,
opened_by: ctx.user.id,
opened_at: now()
});
}

The data.create call re-enters the engine, so the new approval_task row is subject to that entity’s own access rules, row-level security and validations — evaluated with an empty principal, as above, not with the identity that issued the original insert.

PropertyBehaviour
ScopeOne BEGIN per transactional call. A single-entity POST / PUT / PATCH / DELETE is not wrapped — it is one autocommit statement
Multi-operation envelopeThe multi-entity bulk request body runs every operation in one transaction; any failure rolls back all of them
Bulk updateOpens one transaction and issues one full update per record
IsolationCaller-supplied isolation level, read-only flag, and per-transaction schema. The schema option issues a SET search_path after BEGIN on PostgreSQL
CommitThe callback returning nil commits
RollbackThe callback returning an error rolls back and returns that error
PanicRolls back, then re-raises the original panic
NestingRefused. A transactional call on an already-transactional engine returns exec: nested transaction not supported. There is no savepoint support
No factoryexec: no backend configured

Closing an engine flips a closed flag and polls the in-flight counter until it reaches zero or the context expires. After that every operation returns exec: engine closed without touching the pool. Closing the engine does not close the pool — pool ownership stays with the caller, and several engines can share one pool and drain independently.

PathPhases that run
Bulk createBeforeValidate and AfterValidate only, per record
Bulk updateThe full chain, once per record, inside one transaction
Bulk deleteThe full chain once for the whole batch — it compiles to one DELETE … WHERE id IN (…), honouring ?purge

A named action runs the same chain as entity CRUD, with the action and its parameters on the hook context instead of an entity payload:

  1. Resolve the action and pick the section by HTTP method — GET→read, POST→create, PUT/PATCH→update, DELETE→delete. A missing section is a 405 carrying an Allow header.
  2. Validate parameters: unknown names rejected, defaults applied, required and enum enforced.
  3. Inject :user_id, :tenant and :roles; force-set {{schema}} and {{datastore}}.
  4. BeforeValidateAfterValidate → render SQL → BeforeExec → execute → AfterExecAfterScan when rows were returned.

Note the order at the end: on the action path AfterExec runs before AfterScan.

Sharp edgeDetail
{{schema}} and {{datastore}} are never caller-overridableIdentifiers cannot be bound as values, so a caller-supplied override would let a qualified identifier escape into another tenant’s schema
:roles is a comma-joined stringSo role IN (:roles) is portable. Split on the comma in SQL for real array semantics
Parameter type coercion is permissiveOnly the enum check is strict; the raw value passes through. Do not rely on the declared type for sanitisation

A scripted endpoint is a whole HTTP handler written in script. It does not run on the hook chain — the request never resolves an entity, so no phase fires. The chain is reached only when the script calls data.*, and then it is the chain of whatever entity that call targets, with no principal on it — the same empty-identity re-entry described above.

Two consequences for the pipeline: an endpoint is not wrapped in a transaction (tx-mode: parses and is never acted on), and its script runs under the same absent-timeout conditions as every other script here.

The HTTP layer classifies by error type first — access denial 403, validation 422, not-found 404 — and falls back to matching the message text for untyped errors: access denied / not authorised / not authorized / forbidden → 403, not found → 404, validation / invalid / is required → 422, everything else → 500. The full status rules and the code table are on error codes.

MessageRaised byHTTP
hook <name> (<Phase>): <cause>Wrapper applied to every hook failurefrom the cause
access denied (tier <n>): <reason>The access hook refused the operation403
the rule’s own message:Field or entity validation refused the write422
invalid transition on <entity>.<field>: <reason>The stateflow refused the transition422
trigger "<name>": operation aborted by triggerA before_* trigger returned false500
pointcut <Method>/<Name>: <cause>A synchronous pointcut script failed500
exec: entity not foundNo rows matched, or the named action is not registered404
exec: forbiddenThe database refused the statement — insufficient privilege403
exec: validation failedThe database refused the statement — not-null or check violation422
exec: conflictDuplicate key or foreign-key violation500
exec: engine closedAn operation arrived after shutdown began500
exec: nested transaction not supportedA transaction was opened inside a transaction500
exec: no backend configuredNo transaction factory is wired500
exec: no compiler availableNo SQL compiler for the backend500
POINTCUT_ABORTDefault code for a Before* pointcut returning false or __abort without a code
POINTCUT_ERRDefault code for a non-observer pointcut script crash
script: unsupported runtimeThe language: token is not one of the eight accepted values500
script: expression did not return a booleanA guard returned a non-coercible value500
script evaluation: file-referenced scripts not yet wiredA script reference used script-file: with no inline body500
pointcut "<name>": unknown method "<m>"Schema-load failure — the method name is misspelled or unsupportedload fails
servicefn: not yet implementedA stubbed legacy script function was called500
  • Payload edits stop mattering after AfterValidate.
  • AfterScan does not run on entity writes, so a created row is echoed back unmasked and without virtual computed fields.
  • after_* triggers, events:, subscriptions:, transforms: and field encryption all load and none of them run.
  • The beforeRead, afterRead, beforePurge and afterPurge slots of the legacy hooks: block can never fire.
  • A trigger cannot modify the payload; a pointcut must return __patch to do so.
  • The write-time computed-field hook runs one phase too late for its value to reach the column.
  • ctx.headers, ctx.field, ctx.fieldName, ctx.bulkChunk, ctx.error and ctx.reason are always undefined.
  • async: true is ignored on every Before* method, and async work is dropped under backpressure.
  • A crash in an After* pointcut is swallowed.
  • script-file: never executes in a hook, pointcut, trigger or rule.
  • No script has an execution timeout, a memory cap or a stack limit, and none of them receive the request context.
  • JavaScript, Lua, Starlark and Go scripts get host filesystem access.
  • A data.* call from a script re-enters the chain with no principal — empty user, no roles, deny-by-default inactive.
  • Bulk create runs two phases out of five and can leave audit columns blank.
  • Transactions do not nest, and a single-record write over HTTP is not in one.
ConcernPage
The four access tiers and their rule vocabularyAccess rules
Rule-to-WHERE translation and its failure modeRow-level security
Declarative field and entity rulesValidations
State machines, guards and transition scriptsStateflows
Computed fields, virtual and materializedFields
History strategies and what each one writesEntities
Query-string flags the bridge hook readsRequest flags
Custom HTTP routes implemented in scriptScripted endpoints
The published pointcut interfacesPointcuts
Entities served over HTTP instead of SQLRemote entities
Endpoint-level HTTP referenceData API reference