Pages, collection totals and atomic changes
Three exercises: show the next catalog page, calculate totals across an eligible collection, and claim the last seat without saving half a booking.
Na tej stronie
A catalog page, a collection report and a booking solve different problems. A catalog loads the next portion of rows. A report needs every matching record. A booking may need several changes to succeed together.
| Goal | Action |
|---|---|
| Show a catalog in small portions | Read collection page (record_page) |
| Calculate across all eligible matching records | Aggregate whole collection (record_aggregate) |
| Check conditions and save several changes together | Atomic collection transaction (collection_transaction) |
Use sample collections for these exercises. Before using a production project, check the flow in draft testing: a normal transaction run really changes records.
Lesson 1. Show the next catalog page
Create a classes collection with text fields code, title, state and a number field price. Add five available classes with different codes and prices, all with state=open.
- Add Read collection page. Select
classes, filterstateequal toopen, sort bypriceascending and set page size to2. - Save the result as
catalog_page. Leave the cursor empty for the first request. - Save the reaction and reopen it. The continuation button needs the saved reaction's ID.
- Select Add next-page button. The helper adds a draft message with the current page's rows and a Next button. Format
catalog_page.rowsas needed, then save your changes. - Check the transition: the helper passes the next-page cursor while retaining the same collection, filters, sorting, page size and selected fields.
Selecting Next runs this reaction again, including its other actions. Check that they are appropriate for repeated catalog browsing. The helper is available for a page in the reaction's main action list; navigation inside a loop or block is configured manually.
Expect three pages: two rows, two more rows and the last row. On the last page, has_more=false and next_cursor is empty. There should be no next-page button. Submitting an empty cursor again starts from the beginning.
The button uses the navigation array catalog_page.navigation: it contains one item while another page exists and is empty at the end. The helper configures a dynamic button from this array and passes the cursor through Carry. An empty array therefore produces no button. How dynamic buttons work.
A cursor continues one specific query. Do not parse or edit it manually. Keep the same filters, sorting, page size and selected fields. After changing the query, open the catalog with a fresh event carrying no old cursor. An old continuation button should report an incompatible query rather than silently return to page one.
When you duplicate the reaction, the copy's next-page button opens the copy itself. If you then change its filters or sort order, both the first page and continuation use the copy's settings. Links to other reactions keep their existing targets.
A cursor expires one hour after the walk starts. Changes to the collection schema or retention settings also invalidate it. On such an error, offer to reopen the catalog; do not silently replace the requested continuation with the first page.
A class inserted during a walk does not enter that already-started walk. The walk is still not an immutable snapshot: deleted or expired rows, and rows displaced by the record-count limit, disappear. Edits to filter or sort fields can cause an existing row to be skipped or shown again. Restart for a fresh catalog.
What page completeness means
count is the number of rows on this page, not the collection's total class count.
has_more=false means there are no rows after this page. It does not mean this page contains the entire collection. This matters especially after the first page: the last two rows cannot stand for all classes in a total.
When processing a page as a list, use the entire catalog_page source. Selecting only catalog_page.rows drops completeness metadata. List result behaviour.
Configure filters and ordering
Select fields declared in the collection. Each field's type determines the available conditions. Every condition you add must be satisfied.
| Condition | Available for | Example |
|---|---|---|
Equal / not equal (eq, neq) | Text, number, boolean, date | state equals open |
Greater / at least / less / at most (gt, gte, lt, lte) | Number, date | price is at least 1000 |
Contains / starts with (contains, prefix) | Text, case-sensitive | name starts with Watercolour |
Empty / not empty (empty, not_empty) | Any field | phone is empty |
Empty includes a missing field, null and an empty string. Leave the condition value unset. Equal and not-equal conditions skip missing fields and null; use empty to find them.
Use an inclusive start and an exclusive end for a period. For all of October in UTC, use a date at least 2026-10-01T00:00:00Z and less than 2026-11-01T00:00:00Z. Always include Z or an offset such as +03:00. A condition's value may come from a variable; select the collection and field names explicitly.
Dates compare as instants: 12:00:00+03:00 and 09:00:00Z on the same day are equal. Comparison supports nanosecond precision, nine fractional-second digits. Extra digits are discarded without rounding. Invalid times such as 24:00:00 return an error.
The default order puts new records first. With custom ordering, a second field breaks ties in the first. Missing values appear last in either direction. Record ID stabilizes the order when all selected values are equal.
Select only the output fields you need when useful. The original record ID is retained. This helps keep the page within its size limit. If a qualifying record has an invalid number or date used by a condition, the action returns an error. Correct the data or condition; a partial page is not reported as a successful result.
Check a read in the editor
In the page and totals actions, open Check collection read. This sends a real read-only request to the selected collection. It shows the raw response and does not run subsequent reaction actions.
- If a condition contains a variable such as
{{ctx.customer}}, enter a concrete sample below, such asAnna. No request is sent until a sample is provided. Select Use an empty string for an intentionally empty value. - Leave the preview cursor empty for page one. To check continuation, use a cursor returned in the response. This input does not change the action's authored cursor template.
- Select Read without changes, then check the rows and completeness. Samples do not replace variables in the saved settings.
Changing the query, schema or access clears the previous preview. On an old-cursor error, Start a new traversal clears only the preview cursor; explicitly run the new read afterwards. Use draft testing to check the whole chain with variable substitution.
Lesson 2. Calculate revenue across matching orders
Create an orders collection with text fields category, state and a number field amount:
| category | state | amount |
|---|---|---|
| watercolor | paid | 1500 |
| watercolor | paid | 1800 |
| clay | paid | 2000 |
| clay | cancelled | 900 |
Add Aggregate whole collection:
| Setting | Value |
|---|---|
| Collection | orders |
| Filter | state equals paid |
| Group by | category |
| Count metric | count, alias order_count |
| Sum metric | sum of amount, alias revenue |
| Save result as | sales |
Expect two rows: watercolor has count 2 and revenue 3300; clay has count 1 and revenue 2000. The cancelled order is excluded. Remove grouping to get one row with order_count=3, revenue=5300.
Group order is unspecified. If the response needs a particular order, explicitly sort the resulting list, for example by revenue descending.
In the grouped result, metadata count and total are 2, the number of groups. input_count and scanned are 3, the orders that matched the filter and entered the calculation. Each row's order_count metric counts orders within that category.
With no matching orders, an ungrouped result still has one row with order_count=0 and revenue=0. A grouped result has no rows. Check input_count or the count metric to detect orders; the presence of a single total row does not establish that any orders matched.
Choose a metric
| Metric | Calculation |
|---|---|
count | Records in each group |
sum | Sum of numeric field values |
avg | Average of numeric field values |
min, max | Smallest and largest number |
count_distinct | Different non-null field values |
A missing field or null is excluded from that field's calculation. count still includes the record. With no numeric values, the sum is 0; average, minimum and maximum are null. Invalid text instead of a number causes an error. count_distinct supports text, numbers and booleans; number 1 and text "1" are distinct values. An empty string also counts as a distinct value.
Decimal sums are exact. Averages round to 12 fractional digits. Try adding avg of amount with alias average: without grouping, expect 1766.666666666667. Calculate an overall order average without grouping so each order has equal weight.
This action calculates across all eligible collection records that match the filters. It is not limited to the first page or to rows loaded into context earlier. Collection retention rules still apply: expired records and rows excluded by the record-count limit do not reappear in reports.
For a period report, filter your date field. For example, include the lower boundary and exclude the upper boundary so adjacent periods do not count a boundary record twice. The field must have a date-and-time type.
Compare the two operations: load only two paid orders and calculate them with Groups and totals (list_aggregate). That result covers the loaded list only. Use record_aggregate for a collection report.
If time, output group or byte limits are exceeded, the action fails. A partial sum is not published as a whole-collection total. Narrow the period or grouping and try again.
Lesson 3. Claim the last seat and create a booking together
Let seats contain one sample record with code=wc-saturday, state=open. The bookings collection has text fields seat_code, guest_id, state.
Closing the seat and creating the booking must succeed together. Two ordinary actions can leave only the first change saved if the second fails. Add Atomic collection transaction with three operations:
- Assert: select the sample
seatsrecord by ID and expectstate=open. - Update if: expect
state=openon that record and change it tostate=booked. - Create: add a
bookingsrecord withseat_code=wc-saturday,guest_id={{user_id}},state=confirmed.
Save the result as booking_result and configure outcomes:
| Outcome | Response |
|---|---|
committed | “Your seat is booked” |
conflict | “This seat has already been taken. Choose another” |
error | “We could not complete the booking. Try again later” |
On success, both changes are saved. If any guard fails, the result is conflict and every change in the transaction rolls back. The response does not expose current values from someone else's or a changed record. Invalid data and database errors are a separate error outcome.
A conflict stops the sequence even if no conflict destination was selected. A later success message must not run. Configure all three outcomes to give the user a useful response. Outcome rules.
Test concurrency and retries
- Run the booking for two sample users at nearly the same time. Expect one booking and one conflicting run.
- To test rollback, reset the seat to
openand temporarily append anotherassertexpecting that same record to remainstate=open. The earlier update sets it tobooked, so the last guard conflicts. After rollback, the seat must remainopenand no new booking should exist. Remove this final test guard afterwards. - Distinguish a new user request from reprocessing the same event after a failure. For the same operation, the backend saves the result alongside the changes and returns it on retry without creating another booking. A new request checks the seat again.
For API calls, retain the same operation key for retries of the same request. Changed parameters with the old key are rejected. A new independent request needs a new key; a single permanent key cannot cover all bookings.
REST and MCP use the invocation_id field. Its namespace belongs to a particular bot and API user. Rotating that user's token preserves this identity; a call by another user is a separate operation even with the same key.
The conflict result is saved too. If the seat becomes available later, retrying the old request still returns the earlier conflict. A new booking attempt after conditions change must be a new request.
A replayed result confirms the earlier commit. If a record was later changed or deleted, read its current state separately. Replaying the old operation does not recreate it.
A new record's ID is available after the transaction finishes. Intermediate-result templates between operations are not supported, so this example uses a seat code known in advance.
Creating the booking is the third operation in this example. In the committed reaction, its ID is available as {{ctx.booking_result.operations.2.record_id}}: operation numbering starts at zero. The overall result is {{ctx.booking_result.status}}. Conflicts return no IDs from rolled-back operations.
After reordering operations, check the index in result paths: operations.2 always means the current third operation.
Confirmation after commit
Optionally add a text confirmation in the transaction settings. The intention to send it is saved together with the changes. Conflicts and rollbacks enqueue nothing, and reprocessing the same operation does not enqueue another message.
This setting supports text. Configure buttons, files and other sends as ordinary actions, separately from the transaction.
Access to the current conversation is checked before execution. If the bot can no longer post there or the recipient cannot be verified, the attempt fails without new changes. Losing access after commit may prevent delivery; it does not undo the saved booking.
For groups and channels, this confirmation currently supports Telegram when the bot's current posting permission is verified. In groups on other channels, turn this setting off and add an ordinary send on the committed outcome: it is a separate action outside the atomic commit. Private conversations also support nonnumeric IDs when the recipient's link to the current user is verified.
With this option, disconnect the sample committed route to the booking message so the confirmation is configured in one place. If more actions are needed after success, route committed to those actions instead. Configuring both sends creates two separate messages.
Saving a confirmation to the queue does not mean the user has received or read it. Check delivery in the channel log. Payments and other external services do not become part of a collection transaction.
Limits per run
| Action | Limit |
|---|---|
| Read collection page | Default 100, maximum 1000 rows |
| Page result size | Up to 16 MiB |
| Page filters | Up to 64 conditions |
| Page sorting | Up to 8 fields |
| Selected page fields | Up to 256 fields |
| Cursor | 1 hour from the start of the walk |
| Aggregate whole collection | Up to 4 group fields and 64 metrics |
| Grouped output | Up to 10,000 groups and 16 MiB |
| Transaction | Up to 32 operations and 16 collections in one project |
| Transaction confirmation | Up to 4 text messages, each within 4096 UTF-8 bytes and the channel limit |
| Transaction payload | Up to 1 MiB |
These limits do not make a page a complete report or permit partially saved transactions. Filter large reports; split independent work according to its purpose.
Related: collections, list processing, blocks with typed inputs and outputs.