Nested writes
POST and PATCH /api/v1/records/{entity} accept a nested structure in one request: the
record's own fields, its translatable _lng companion, and inline child-collection arrays —
recursively, in a single transaction. The engine recognises child tables from metadata and wires the
parent foreign key automatically, so you create or edit a whole tree with one call.
This is the same generic engine that backs every entity; there is no per-entity code.
The shape of a payload
A write body is the record's own fields, plus two optional kinds of nested data:
<entity>_lng— the translatable companion (see Concepts → Translations), keyed by language code.- One array per child table that references this entity — keyed by the child table name
(e.g.
syntec_group_roleundersyntec_group). The engine discovers these from the foreign keys in the metadata; any table with a reference back to the parent is eligible.
Anything else in the body is treated as one of the record's own fields (unknown keys are ignored).
Create (POST)
POST /api/v1/records/syntec_group
{
"cid": "TST_GRP_1",
"syntec_package_id": "019ebf06-f968-7a4b-bc8b-39c210b8be46",
"syntec_group_lng": { "EN": { "name": "Test group 1" }, "NL": { "name": "Test groep 1" } },
"syntec_group_role": [
{ "syntec_role_id": "019ec2fb-2d26-72d9-b350-17b0878277e8" },
{ "syntec_role_id": "019ec2fb-2d26-7e19-b350-17b087a555fa" }
]
}
Creates the syntec_group, its _lng rows (one per system language), and the two
syntec_group_role rows. You do not send the child's parent FK (syntec_group_id) — the engine
injects the new group's guid into each child, overriding anything you put there. References are
always guids, never raw ids.
Response: 201 { "guid": "<group-guid>" }. Children are not echoed back — re-fetch the record if you
need their guids.
Update (PATCH) — sync semantics
On PATCH, including a child-collection key syncs that table: the array is the desired set.
PATCH /api/v1/records/syntec_group/{guid}
{
"syntec_group_role": [
{ "guid": "019ec3a1-…", "provenance": "package" }, // has guid -> update this row
{ "syntec_role_id": "019ec2fb-…" } // no guid -> create a new row
]
// any existing role NOT listed here -> set inactive
}
| Row in the array | Result |
|---|---|
has a guid |
that child is updated (must be an active child of this parent) |
no guid |
a new child is created (parent FK wired) |
| an existing active child omitted from the array | set inactive (status = 2) — not deleted |
If the child key is absent from the payload, that child table is left completely untouched
(exactly like _lng). So PATCH with only {"name": "…"} changes the parent and nothing else.
The _lng companion follows the usual PATCH rule: only the languages you send are updated; omit it to
leave translations alone.
Recursion
Nesting is uniform at every level: a child can carry its own _lng and its own child
collections, and they are written depth-first in the same transaction. For example, create a view
with its columns and each column's label in one call:
POST /api/v1/records/syntec_view
{
"cid": "demo_list",
"syntec_view_column": [
{ "col_key": "name", "syntec_view_column_lng": { "EN": { "label": "Name" } } }
]
}
The nesting depth is capped (10 levels) as a safety limit; deeper payloads are rejected with 422.
Guarantees
- Atomic. The whole tree is written in one transaction. If anything fails — a validation error, a permission denial, a bad child reference — nothing is persisted, parent included.
- Validated, permission-checked, and audited per node. Every record in the tree goes through the same engine path as a flat write: field/row-level permissions, field validation, guid-reference resolution, and the audit trail all apply to each child independently.
- No reparenting. Because the engine forces the parent FK and rejects child guids that don't belong to this parent, a client cannot move or hijack another record's children through a nested write.
Rules & caveats
- The
_lngcompanion is mandatory on create. If an entity has a<entity>_lngtable, creating a record always writes companion rows and validates the required translatable columns — even if you omit the_lngkey. On update it is optional (only touched when you send it). - A bad child guid fails the whole write. A
guidin a child array that is not an active child of the record you're writing →422, full rollback. (This includes a guid from a different parent, or an already-inactive child.) - Omitted children go inactive, not deleted. They are set to
status = 2; they are not archived or removed. A later sync that lists them again re-creates an active row. - Scoped principals sync only their slice. For a row-scoped (e.g. portal) principal, the sync only considers and affects children visible within its row scope; children outside its scope are neither deactivated nor referenceable.
- Response is the parent guid only (
201 { "guid" }/204). Re-fetch to read back the tree.
Where it lives
RecordApi::create/update delegate to NestedRecordWriter (app/src/Api), which walks the tree
and composes the generic EntityRepository and CompanionLanguageWriter — so nested writes inherit
everything those already enforce. ChildRelationResolver provides the "which tables are children of
this entity" lookup from metadata. The interactive schema for the POST/PATCH request bodies is on
the API reference page (the live Swagger UI is also at /api/docs).