Repository navigation
Ordered Tree Tables
This guide describes core/ordered_tree/v1, checked against the dev source on 2026-10-07. Examples are synthetic contract illustrations, not captured production responses. Record examples omit audit and other model fields; the HTTP data / meta envelope is shown. Replace IDs with records in your workspace.
- The built-in
departmentsmodel is visible in the main data source (/settings/data-models?source=main) and is read-only through generic runtime APIs. Use organization console endpoints to create, edit, delete, assign roles, and manage membership. - A custom writable model using the ordered-tree template exposes the generic operations below, subject to permissions. To demonstrate all operations, create a model with code
org_demoand a required string business fieldname. It illustrates an organization hierarchy but does not become the system department directory. - Authentication and current workspace access are required. Cookie-authenticated writes use the existing CSRF protocol (
x-csrf-token). Examples omit credentials. Do not treat cursors as authorization.
flowchart TD
Root["组织 / Organization: virtual root"] --> A["研发中心 / R&D"]
A --> B["平台部 / Platform"]
A --> C["应用部 / Applications"]
B --> D["基础设施组 / Infrastructure"]
| Node / 节点 | ID |
|---|---|
| 研发中心 | 00000000-0000-4000-8000-000000000001 |
| 平台部 | 00000000-0000-4000-8000-000000000002 |
| 应用部 | 00000000-0000-4000-8000-000000000003 |
| 基础设施组 | 00000000-0000-4000-8000-000000000004 |
“组织” is a virtual browsing root, not a database row. Top-level departments have parent_id: null. Selecting the virtual root shows all users, including unassigned users. Selecting a department shows deduplicated users in that department and all descendants. Generic tree tables can have multiple top-level records.
| Field | Responsibility |
|---|---|
parent_id |
Authoritative parent relationship; null means top-level |
sibling_rank |
Core-managed sibling order; never write it directly |
tree_path |
Hidden derived PostgreSQL ltree column; not a public business field |
scope_id, tree_partition_id
|
Scope and structural partition isolation, managed by the core |
The database maintains paths atomically when nodes move and prevents cycles. Descendant membership uses ltree predicates and a GiST expression index on ARRAY[tree_path]; clients do not query one level at a time to discover a whole subtree. This does not mean every endpoint executes exactly one SQL statement: authorization, cursor validation, search ancestor context, and enrichment can require additional reads. Ordering follows sibling ranks, not UUID path order. Moving a subtree rewrites descendant paths in the same transaction.
Prefix: /api/runtime/models/{model_code}; examples use org_demo.
| Method | Path | Purpose |
|---|---|---|
| GET | /list |
Flat filtered list; does not expand descendants |
| GET | /get/{id} |
Read one record |
| POST | /create |
Create under a parent at a relative position |
| PATCH | /update/{id} |
Update business fields only |
| DELETE | /delete/{id} |
Delete a leaf only |
| GET | /tree/roots |
Page top-level nodes |
| GET | /tree/children/{id} |
Page direct children |
| GET | /tree/ancestors/{id} |
Root-to-parent ancestors, excluding self |
| GET | /tree/descendants/{id} |
Page descendants in depth-first order, excluding self |
| GET | /tree/search |
Business-text prefix search with ancestor context |
| POST | /tree/move/{id} |
Reparent or reorder a node |
| POST | /tree/delete-subtree/{id} |
Delete node and descendants after count check |
Read operations require view; create requires create; update/move require update; both delete operations require delete. Built-in read-only capabilities still apply even when a template describes write operations.
GET /api/runtime/models/org_demo/list?page=1&page_size=20HTTP 200
{
"data": {
"items": [
{
"id": "00000000-0000-4000-8000-000000000001",
"name": "研发中心",
"parent_id": null
},
{
"id": "00000000-0000-4000-8000-000000000002",
"name": "平台部",
"parent_id": "00000000-0000-4000-8000-000000000001"
},
{
"id": "00000000-0000-4000-8000-000000000003",
"name": "应用部",
"parent_id": "00000000-0000-4000-8000-000000000001"
},
{
"id": "00000000-0000-4000-8000-000000000004",
"name": "基础设施组",
"parent_id": "00000000-0000-4000-8000-000000000002"
}
],
"total": 4
},
"meta": null
}GET /api/runtime/models/org_demo/get/00000000-0000-4000-8000-000000000002HTTP 200
{
"data": {
"id": "00000000-0000-4000-8000-000000000002",
"name": "平台部",
"parent_id": "00000000-0000-4000-8000-000000000001"
},
"meta": null
}POST /api/runtime/models/org_demo/create
Content-Type: application/json
{
"name": "基础设施组",
"parent_id": "00000000-0000-4000-8000-000000000002"
}HTTP 201
{
"data": {
"id": "00000000-0000-4000-8000-000000000004",
"name": "基础设施组",
"parent_id": "00000000-0000-4000-8000-000000000002"
},
"meta": null
}Omit parent_id or send null to create a top-level node. Omit position to append.
PATCH /api/runtime/models/org_demo/update/00000000-0000-4000-8000-000000000004
Content-Type: application/json
{
"name": "基础平台组"
}HTTP 200
{
"data": {
"id": "00000000-0000-4000-8000-000000000004",
"name": "基础平台组",
"parent_id": "00000000-0000-4000-8000-000000000002"
},
"meta": null
}Do not send parent_id or sibling_rank here; use tree/move. Each example is independent.
GET /api/runtime/models/org_demo/tree/roots?limit=100HTTP 200
{
"data": {
"items": [
{
"id": "00000000-0000-4000-8000-000000000001",
"name": "研发中心",
"parent_id": null
}
],
"has_more": false,
"next_cursor": null
},
"meta": null
}GET /api/runtime/models/org_demo/tree/children/00000000-0000-4000-8000-000000000001?limit=100HTTP 200
{
"data": {
"items": [
{
"id": "00000000-0000-4000-8000-000000000002",
"name": "平台部",
"parent_id": "00000000-0000-4000-8000-000000000001"
},
{
"id": "00000000-0000-4000-8000-000000000003",
"name": "应用部",
"parent_id": "00000000-0000-4000-8000-000000000001"
}
],
"has_more": false,
"next_cursor": null
},
"meta": null
}Roots and children return records directly in items, without a universal has_children wrapper. The organization-specific list adds that field.
GET /api/runtime/models/org_demo/tree/ancestors/00000000-0000-4000-8000-000000000004HTTP 200
{
"data": [
{
"id": "00000000-0000-4000-8000-000000000001",
"name": "研发中心",
"parent_id": null
},
{
"id": "00000000-0000-4000-8000-000000000002",
"name": "平台部",
"parent_id": "00000000-0000-4000-8000-000000000001"
}
],
"meta": null
}GET /api/runtime/models/org_demo/tree/descendants/00000000-0000-4000-8000-000000000001?limit=100&include_path=trueHTTP 200
{
"data": {
"items": [
{
"record": {
"id": "00000000-0000-4000-8000-000000000002",
"name": "平台部",
"parent_id": "00000000-0000-4000-8000-000000000001"
},
"depth": 1,
"has_children": true,
"path": [
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000002"
]
},
{
"record": {
"id": "00000000-0000-4000-8000-000000000004",
"name": "基础设施组",
"parent_id": "00000000-0000-4000-8000-000000000002"
},
"depth": 2,
"has_children": false,
"path": [
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000002",
"00000000-0000-4000-8000-000000000004"
]
},
{
"record": {
"id": "00000000-0000-4000-8000-000000000003",
"name": "应用部",
"parent_id": "00000000-0000-4000-8000-000000000001"
},
"depth": 1,
"has_children": false,
"path": [
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000003"
]
}
],
"has_more": false,
"next_cursor": null
},
"meta": null
}depth is relative to the requested node (children are 1). path starts at that node and includes the result node; it is an ID array, not the raw ltree column. Without include_path=true, path is null. max_depth=1 returns only direct children; omit max_depth to traverse all depths, page by page.
GET /api/runtime/models/org_demo/tree/search?prefix=%E5%9F%BA%E7%A1%80&limit=20HTTP 200
{
"data": {
"items": [
{
"record": {
"id": "00000000-0000-4000-8000-000000000001",
"name": "研发中心",
"parent_id": null
},
"is_match": false
},
{
"record": {
"id": "00000000-0000-4000-8000-000000000002",
"name": "平台部",
"parent_id": "00000000-0000-4000-8000-000000000001"
},
"is_match": false
},
{
"record": {
"id": "00000000-0000-4000-8000-000000000004",
"name": "基础设施组",
"parent_id": "00000000-0000-4000-8000-000000000002"
},
"is_match": true
}
],
"has_more": false,
"next_cursor": null
},
"meta": null
}The prefix is “基础”. Search is case-insensitive over non-system String/Enum/Text business fields, not fuzzy substring search. limit counts matches, while items also includes ancestors. Merge repeated IDs across pages and preserve is_match=true. For the built-in directory, use the organization search endpoint below.
POST /api/runtime/models/org_demo/tree/move/00000000-0000-4000-8000-000000000003
Content-Type: application/json
{
"new_parent_id": "00000000-0000-4000-8000-000000000001",
"before_id": "00000000-0000-4000-8000-000000000002"
}HTTP 200
{
"data": {
"moved": true
},
"meta": null
}This moves Applications before Platform under R&D. Use after_id to insert after a sibling; do not send both. The anchor must belong to the destination parent. new_parent_id: null moves to the top level; omitting it also resolves to top level, so send the intended parent explicitly when reordering. Omitting both anchors appends. Moving a node under itself or a descendant is rejected.
DELETE /api/runtime/models/org_demo/delete/00000000-0000-4000-8000-000000000004HTTP 200
{
"data": {
"deleted": true
},
"meta": null
}POST /api/runtime/models/org_demo/tree/delete-subtree/00000000-0000-4000-8000-000000000002
Content-Type: application/json
{
"expected_affected_count": 2
}HTTP 200
{
"data": {
"deleted_count": 2
},
"meta": null
}The expected count includes the selected node. Here Platform + Infrastructure = 2. Obtain the current count from the complete descendant traversal plus one; a single page length is insufficient. If concurrent changes alter the count, the command rejects instead of silently deleting a different number. This is a count guard, not an exact node-set snapshot guarantee. A normal delete rejects non-leaf nodes.
{
"data": {
"items": [
{
"id": "00000000-0000-4000-8000-000000000002",
"name": "平台部",
"parent_id": "00000000-0000-4000-8000-000000000001"
}
],
"has_more": true,
"next_cursor": "<opaque-cursor-from-server>"
},
"meta": null
}The cursor above is a placeholder, not a valid token. Pass the actual next_cursor unchanged and URL-encoded as cursor to the same query. Keep the model, scope, partition and filters unchanged. Stop when has_more=false (next_cursor=null).
-
roots,children,descendants: defaultlimit=100; generic search: default20. These are batch sizes, not tree capacity limits. - No application-level 256-depth / 1000-result / 100-search-match caps remain. Inputs must still fit their integer types and PostgreSQL storage limits.
- Invalid or wrong-context cursors return HTTP 400. A deleted, moved or relevantly reordered anchor returns HTTP 409 with
tree_stale_cursor; discard the cursor and reload. - Missing permissions, missing nodes, cycles, invalid positions and subtree count mismatches are failures, not partial success. Inspect the current API error response rather than assuming a successful empty page.
- Anchor validation and each page query share a read-only repeatable-read transaction. Separate requests are a live view, not one long snapshot: concurrent inserts/moves around consumed boundaries may cause omissions or repeats. Reload for the current view; consistent exports require a dedicated snapshot/export mechanism.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/console/settings/departments/access |
Available organization actions |
| GET | /api/console/settings/departments |
Roots / children / search / selected-ID lookup |
| POST | /api/console/settings/departments |
Create with role associations |
| PATCH | /api/console/settings/departments/{id} |
Save name, parent and role associations |
| DELETE | /api/console/settings/departments/{id} |
Delete subject to organization business constraints |
| PUT | /api/console/settings/members/{id}/departments |
Replace all memberships and primary department |
| GET | /api/console/settings/members?department_id={id} |
Users in a department and its descendants |
POST /api/console/settings/departments
Content-Type: application/json
{
"name": "研发中心",
"parent_id": null,
"role_codes": []
}HTTP 201
{
"data": {
"id": "00000000-0000-4000-8000-000000000001",
"name": "研发中心",
"parent_id": null,
"role_codes": [],
"member_count": 0
},
"meta": null
}role_codes is required; send [] for no grant. Use existing role codes to associate roles; this does not create new role records. After creating children, list them as follows (the count of 3 is illustrative).
GET /api/console/settings/departments?parent_id=00000000-0000-4000-8000-000000000001&limit=100HTTP 200
{
"data": {
"items": [
{
"id": "00000000-0000-4000-8000-000000000002",
"name": "平台部",
"parent_id": "00000000-0000-4000-8000-000000000001",
"role_codes": [],
"member_count": 3,
"has_children": true,
"is_match": true
},
{
"id": "00000000-0000-4000-8000-000000000003",
"name": "应用部",
"parent_id": "00000000-0000-4000-8000-000000000001",
"role_codes": [],
"member_count": 0,
"has_children": false,
"is_match": true
}
],
"has_more": false,
"next_cursor": null
},
"meta": null
}Omit filters for roots. Use prefix=基础 for name prefix search, or ids=<id1>,<id2> to resolve previously selected values. parent_id, prefix and ids are mutually exclusive. All modes use the page envelope and default limit=100, including organization search. Items flatten department fields; they are not wrapped in record. member_count counts distinct users across that department's subtree. Normal list results are matches; search-only ancestor context has is_match=false.
PATCH /api/console/settings/departments/00000000-0000-4000-8000-000000000002
Content-Type: application/json
{
"name": "平台部",
"parent_id": "00000000-0000-4000-8000-000000000001",
"role_codes": []
}HTTP 200
{
"data": {
"id": "00000000-0000-4000-8000-000000000002",
"name": "平台部",
"parent_id": "00000000-0000-4000-8000-000000000001",
"role_codes": [],
"member_count": 3
},
"meta": null
}Send the complete save payload (name, parent_id, role_codes), not just the changed field.
PUT /api/console/settings/members/00000000-0000-4000-8000-000000000005/departments
Content-Type: application/json
{
"department_ids": [
"00000000-0000-4000-8000-000000000002",
"00000000-0000-4000-8000-000000000003"
],
"primary_department_id": "00000000-0000-4000-8000-000000000002"
}HTTP 204
(No response body.)
A user may belong to multiple departments; if any are assigned, exactly one must be primary and must appear in department_ids. To clear assignments, send department_ids: [] and primary_department_id: null. Roles attached to directly joined departments supplement existing user roles; ancestor roles are not implicitly inherited. Browsing a subtree does not grant membership or permissions.
Select the virtual root by omitting department_id from the members query. Selecting a real department applies the backend subtree filter regardless of which UI branches have been expanded.
DELETE /api/console/settings/departments/00000000-0000-4000-8000-000000000004HTTP 204, no response body. This requires a leaf with no assigned members; departments with children or member bindings are rejected. The organization console does not expose the generic subtree-delete command as a bulk organization deletion API.
GET /api/console/settings/departments/accessHTTP 200
{
"data": {
"can_list": true,
"can_create": true,
"can_update": true,
"can_delete": true,
"can_replace_member_departments": true,
"can_assign_roles": true
},
"meta": null
}These illustrative flags depend on the current user. The server enforces permissions on every operation.
flowchart TD
A["Open tree"] --> B["Load roots page"]
B --> C{"User action"}
C -->|Expand| D["Load direct children"]
C -->|Search| E["Prefix matches + ancestors"]
C -->|More| F["Same query + next_cursor"]
C -->|Select department| G["Members filtered by subtree"]
C -->|Move custom node| H["Validate parent / position / cycle"]
H --> I["Atomically update parent, rank and descendant paths"]
F --> J{"Cursor valid?"}
J -->|Yes| K["Return page"]
J -->|Stale: 409| L["Discard cursor and reload"]