Skip to content

Ordered Tree Tables

JAVA-Lw edited this page Oct 7, 2026 · 1 revision

Ordered Tree Tables: API Guide with an Organization Example

中文

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.

1. Choose the correct API

  • The built-in departments model 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_demo and a required string business field name. 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.

2. Example hierarchy and storage

flowchart TD
    Root["组织 / Organization: virtual root"] --> A["研发中心 / R&D"]
    A --> B["平台部 / Platform"]
    A --> C["应用部 / Applications"]
    B --> D["基础设施组 / Infrastructure"]
Loading
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.

3. Generic interface inventory

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.

4. Generic requests and responses

4.1 Flat list

GET /api/runtime/models/org_demo/list?page=1&page_size=20

HTTP 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
}

4.2 Read one record

GET /api/runtime/models/org_demo/get/00000000-0000-4000-8000-000000000002

HTTP 200

{
  "data": {
    "id": "00000000-0000-4000-8000-000000000002",
    "name": "平台部",
    "parent_id": "00000000-0000-4000-8000-000000000001"
  },
  "meta": null
}

4.3 Create a node

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.

4.4 Update business fields

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.

4.5 Top-level nodes

GET /api/runtime/models/org_demo/tree/roots?limit=100

HTTP 200

{
  "data": {
    "items": [
      {
        "id": "00000000-0000-4000-8000-000000000001",
        "name": "研发中心",
        "parent_id": null
      }
    ],
    "has_more": false,
    "next_cursor": null
  },
  "meta": null
}

4.6 Direct children

GET /api/runtime/models/org_demo/tree/children/00000000-0000-4000-8000-000000000001?limit=100

HTTP 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.

4.7 Ancestors

GET /api/runtime/models/org_demo/tree/ancestors/00000000-0000-4000-8000-000000000004

HTTP 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
}

4.8 Descendants and paths

GET /api/runtime/models/org_demo/tree/descendants/00000000-0000-4000-8000-000000000001?limit=100&include_path=true

HTTP 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.

4.9 Prefix search

GET /api/runtime/models/org_demo/tree/search?prefix=%E5%9F%BA%E7%A1%80&limit=20

HTTP 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.

4.10 Move / reorder

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.

4.11 Delete a leaf

DELETE /api/runtime/models/org_demo/delete/00000000-0000-4000-8000-000000000004

HTTP 200

{
  "data": {
    "deleted": true
  },
  "meta": null
}

4.12 Delete a subtree

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.

5. Pagination and errors

{
  "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: default limit=100; generic search: default 20. 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.

6. Real organization endpoints

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

6.1 Create a top-level department

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).

6.2 Load direct children

GET /api/console/settings/departments?parent_id=00000000-0000-4000-8000-000000000001&limit=100

HTTP 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.

6.3 Edit a department

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.

6.4 Replace memberships

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.

6.5 Delete an empty leaf department

DELETE /api/console/settings/departments/00000000-0000-4000-8000-000000000004

HTTP 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.

6.6 Check available actions

GET /api/console/settings/departments/access

HTTP 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.

7. Typical loading flow

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"]
Loading

8. Source references

1flowbase Wiki

Quickstart

License

Use Cases

Evaluation Checklists

插件扩展

Engineering

Project

Clone this wiki locally