A screen is one call
Rule: PRF-010.
Why
The business logic runs in the browser (src/lib/api.ts). Every read it makes is a round
trip to the database in Mumbai, about 0.24 s from an office in India. What the user waits
for is the number of round trips that have to happen one after another — a read that needs
the answer of the read before it cannot start until that answer arrives.
Measured on 2026-09-26 by src/lib/perf.bookings.test.ts:
| Screen | Before | After |
|---|---|---|
Bookings list — the page of rows (GET /sales/bookings) |
6 calls, 6 in sequence (~1.4 s) | 1 call (~0.24 s) |
| Bookings list — rows and tiles together | 7 calls, 6 in sequence | 2 calls side by side, 1 in sequence |
Booking page (/sales/bookings/:id) on first paint |
35 calls, 11 in sequence (~2.6 s) | 1 call (~0.24 s) |
The 35 included three separate requirePermission() checks, each of which is itself three
round trips (sign-in user, user overrides, role grants).
How
One database function per screen returns everything the screen shows on first paint as one
jsonb:
booking_list_page(...)— the filters, sort and keyset ofbooking_page, and for each row the customer, group, partner, payer, quotation, passengers and payments, the approvers' names, the next cursor and the total (exact withp_with_total, else the planner's estimate when there is no search).booking_screen(p_booking_id, p_sections)— the booking, its passengers, payments, customer, payer, group, partner, flight labels and approvers' names; withp_sectionsalso what each traveller bought (PAX-034), the journal, the group's rate sheet, the emergency contact, the check-in consent, the issued documents, the corrections, the incident banner and the journey.
api.ts turns the payload into the same response the separate reads produced, with the same
code, so the pages barely changed. BookingDetail asks for GET /sales/bookings/:id?screen=1
and hands each section its part of the answer; a section given its part does not read again.
Who sees what
Both functions are SECURITY INVOKER. Every read inside them runs as the caller, through
the same row-level-security policies the separate PostgREST reads went through, so nothing is
wider than it was. The checks api.ts used to make in the browser before a section's read are
made in the function with auth_user_has_permission(), and the section is null without the
permission:
| Section | Needs |
|---|---|
| What each traveller bought, the journey, the corrections | bookings.view |
| The journal | finance.view |
| The group's rate sheet | groups.view |
| The incident banner | whatever incident_banner() decides |
| Everything else | row security on its table |
The paging still goes through booking_page (SECURITY DEFINER), which confines a partner to
its agency and refuses a caller with no booking permission. supabase/tests/a_screen_is_one_call.sql
compares each function with the reads it replaced for staff, a partner, a customer and a
caller with no permission.
Indexes
Added with the functions, from their plans on a 20,000-booking fixture:
JournalLine("entryId")— there was none, so each voucher's total scanned every line in the ledger (80,000 rows, 28 ms per voucher on the fixture; now an index lookup).VisaCase("bookingId")— the only index led bybookingIdwas partial.JournalEntry("referenceId", "createdAt" DESC)andPayment("bookingId", "createdAt" DESC).
On that fixture booking_screen takes about 22 ms warm (139 ms without the indexes) and a
50-row booking_list_page with the exact total about 32 ms.
What still reads on its own
- The old sub-routes (
/passenger-services,/corrections,/field/*,/groups/:id/pricing,/journey/bookings/:id, the incident banner) are unchanged. Writes, the phone app and other screens use them. - On the booking page: a traveller's flights and hotels (read when the row is opened), the activity tab, the invoice and the customer ledger (read when asked for).
- On the bookings list: the tiles (
booking_stats, one call beside the list) and the pickers the dialogs use — customers, groups, partners, currencies, accounts, visa cases and the finance settings — are still read when the page opens. They are not yet one call. - A database without these functions:
api.tsfalls back to the separate reads.
Customers, leads and the work inbox
Migration 20260930080000_customers_are_one_call.sql, measured by
src/lib/perf.customers.test.ts:
| Screen | Before | After | Function |
|---|---|---|---|
Customers list (GET /sales/customers?paged=1&options=1) |
12 calls, 4 in sequence | 1 call | customers_list_page |
Customer 360 (GET /journey/customers/:id) |
12 calls, 7 in sequence | 1 call | customer_360_screen |
Leads list (GET /leads?paged=1&options=1) |
5 calls, 3 in sequence | 1 call | leads_list_page |
A lead opened by link (?lead=) |
8 calls, 3 in sequence | 2 calls side by side | leads_list_page twice |
Work inbox (GET /work?options=1) |
10 calls, 4 in sequence | 1 call | work_inbox_screen |
The same pattern: SECURITY INVOKER, so the Customer, Lead, Agent, TravelGroup,
User, currencies and work-option reads run under their own row security, and the
functions they call (portal_context, get_customer_360, get_customer_360_tab_meta,
get_audit_timeline, work_inbox_page) decide for themselves as before. The browser checks
the routes made are made in SQL: customers.view for Customer 360 and work.view for the
inbox (both refused with 403), admin.currency.view for the currencies picker (left out
without it). A partner reading the customers list still gets its own agency, masked.
options=1 adds the pickers a screen's dialogs use to its first page, so the lists no
longer read partners, currencies, departures or the work filters separately. The keyset is
the same as paging; api.ts still builds the cursor.
supabase/tests/customers_are_one_call.sql compares each function with the old reads for
SALES_EXEC, SALES_MANAGER, OPS_EXEC, FINANCE_MANAGER, AUDITOR, a viewer without
customers.view and a partner login.
Indexes, from EXPLAIN on 30,000 customers and 3,000 partners: live customers by
(createdAt DESC, id DESC) and live partners by (createdAt DESC, id).
Still on its own: older activity entries, the lead opened by link (beside the list), a search's pickers (not re-read), and the sidebar and top bar on every screen.
Other screens have not been converted yet. A new screen should be built this way from the start.
Groups
The Groups screens (20260930150000_groups_are_one_call.sql), the same pattern:
| Route | Function | What it holds |
|---|---|---|
GET /groups/screen?scope= |
groups_list_screen() |
the groups, the airline on each group's flights, which groups have hotels and transfers, the new-group dialog's block and FIT pickers |
GET /groups/:id/screen |
group_screen(p_group_id) |
the group (with its leader), bookings with passengers and live paid figures, what each traveller is on (rooms, meals, transfers, flights, tickets) and who left by transfer (PAX-035), each traveller's family, family status and guardian (PAX-036, PAX-021 — carried on the passengers by prf_booking_passengers, so the booking page gets them too), flights, hotels, meals, transfers, visa cases, expenses list, documents, hotel inventory, the cancellation-policy picker, incident banner, traveller states, last check-ins, tab counts |
GET /groups/:id/financials |
group_financials_screen(p_group_id) |
every row the cost report and the P&L read |
GET /groups/:id/invoices-screen |
group_invoices_screen(p_group_id) |
the invoices with their payers, the bookings, and only the partner and customer names the tab shows |
GET /groups/:id/programme |
trv_trip_programme(p_group_id) |
the programme (INV-008): the activities, the itinerary and the notices — read when the Itinerary tab or the itinerary preview opens, and by Copy and Print on demand |
Who sees what: SECURITY INVOKER, so every table is read under its own row security. The
checks the routes made in the browser are made in SQL, and the section is null without them:
groups.view for the group row and the documents, visa.view for the visa cases,
admin.view or groups.view for the policy picker, finance.view or groups.view for the
P&L, group_invoices.view for the invoices (the tab is refused with 403, as before).
incident_banner, group_traveller_states and fld_group_checkins are called as before and
decide for themselves. A customer or partner login gets the public departure list from
/groups/screen and a 403 from the others.
The cost report and the P&L are not rewritten in SQL. group_financials_screen returns the
rows the two reports read (bookings, passengers, payments, flights with their blocks, seats,
airline penalties, hotels, meals, transfers, commissions, expenses, vouchers, open posting
failures, customers, partners, earlier bookings), and api.ts runs the same code as
GET /groups/:id/expenses and /financial-summary over them through a snapshot reader
(GroupFinanceReader). The routes use a live reader. A test runs both on the same rows and
compares every figure.
The page reads them with React Query. The list keeps the last result on screen while another
scope loads (placeholderData: keepPreviousData). A group is refreshed every time it is
opened (staleTime: 0) and shows its own cached copy meanwhile, never another group's. The
incident banner, check-ins, documents and traveller states take their rows from the group's
call and read alone only if it failed. Each route falls back to the old reads on a database
without the function.
Indexes: GroupHotelAssignment ("groupId") (none existed, so every read of a group's hotels
scanned every assignment) and VisaCase ("bookingId"). With sequential scans off, every
per-group read in the four functions is an index scan.
Still on its own for Groups: the Passengers tab's traveller journeys, and the Pricing, Website and Activity tabs.
Operations screens
The visa, approvals, inventory, hotel and ticket screens
(20260930210000_operations_are_one_call.sql), the same pattern. Routes, and what each
section is left out without, are on the Operations screens API page;
the counts before and after are in PRF-010.
| Route | Function |
|---|---|
GET /screens/visa |
visa_list_screen — a keyset page, the exact total, the stage counts, and on first load the visa groups and the travel-group filter |
GET /screens/visa/:id, /screens/visa-groups/:id |
visa_case_screen, visa_group_screen |
GET /screens/approvals |
approvals_screen — booking_list_page and booking_stats over the same filters |
GET /screens/airline-blocks?blockId= |
airline_blocks_screen — blocks, airlines, suppliers, offers, departures, the bookings on linked departures (walked through booking_page), block payments, accounts, currencies, drafts, and a block's manifest |
GET /screens/seat-releases, /screens/inventory-holds |
seat_releases_screen, inventory_holds_screen — the queue with what each row names, and the forms' pickers |
GET /screens/hotels |
hotels_screen — hotels, departures, lettings, suppliers, accounts, today's rates, the B2B bed offers |
GET /screens/tickets/:id |
ticket_screen — the ticket, its booking, passenger, history and the departure's flights |
The functions return the raw rows — the columns and embedded objects the separate reads
fetched — and the handler (src/lib/opsScreens.ts, a lazy chunk, so the API layer every
screen downloads does not carry it) shapes them with the functions the separate routes use:
shapeQuotaBlockRows, shapeBlockManifest, shapeVisaCaseDetail, buildGroupFlightSnapshot,
listStaffGroups, mapHotelInventoryRows and the rest. src/lib/api.opsScreens.test.ts
checks each screen equals its separate routes on the same rows. The client
(src/services/opsScreenService.ts) falls back to the separate routes on a 501.
Who sees what: the screen's own list is refused with 403 without the permission its route
asked for (visa.view, visa.groups.view, inventory.view, hotels.view, tickets.view;
the approval queue as booking_page decides). Side lists are null without theirs:
visa.groups.view, finance.view (the chart of accounts; without it the FIN-044 accounts
come from airline_block_paid_from_accounts), admin.currency.view,
inventory.create/inventory.edit (drafts), agents.view (B2B bed offers).
supabase/tests/operations_are_one_call.sql checks every section against the person's own
reads for VISA_OFFICER, OPS_MANAGER, TICKET_MANAGER, TICKET_EXEC and a login with no role.
The pages keep each answer in the React Query cache under ['screen', …]: opened again,
a screen paints at once and refreshes behind. The city list behind every city picker is
read when a form that shows it opens (useServiceCities(…, { enabled })), and then shared
for ten minutes (fetchActiveServiceCitiesShared()).
One read path wrote: GET /suppliers checked, and created if missing, a creditor ledger
for every supplier on every load — four requests a supplier, on the airline blocks and
hotels screens. The screens do not; the ledger is still created when a supplier is saved
and by fin_ensure_posting_account when a posting needs it.
Indexes, from EXPLAIN with sequential scans off: VisaDocument ("visaCaseId") and
VisaStatusHistory ("visaCaseId"). Every other read already had one.
Still on its own: a dialog opened from a screen (a visa case's documents, a block's payment form, pricing a release).
The partner and employee records
The partner record (/partners/:id, the app's admin/partners/[id]) is one call:
partner_record(p_agent_id) (PTR-083, agents.view,
SECURITY DEFINER). Since 20261001234000 the same call also carries the partner profile,
the contacts, the last 50 notes, the performance numbers and the login's sessions, devices
and sign-ins (PTR-084 … PTR-089) — no second
round trip was added. Since 20261002100000 it also carries the latest 50 travellers and
their count for the profile page's Travellers tab (PTR-091) — still
one call. The employee record is one call the same way (employee_record). The
Partners list adds one read beside its own (partner_list_extras, the city, tier,
relationship manager, owner and primary contact — PTR-090,
PTR-092); it is not in sequence with the list's reads' answers.
Still on its own: the record's dialogs (Edit profile, a contact), which load their code only when opened; the relationship-manager picker reads the staff list when Edit profile opens.
The phone's Operations tab
Migration 20261008235000_app_operations.sql, for the native app
(The Alhuda Travels app → Operations,
UX-024).
| Screen | Function |
|---|---|
| Staff → Operations | app_operations_summary() — SECURITY DEFINER, read only. One line per area, each section only for the permission that reads it: airline blocks, ground and holds with inventory.view; seat releases with inventory.view, tickets.view or finance.view; the week's unacknowledged deadlines (dash_deadline_radar) with inventory.view or tickets.view; hotels per city with hotels.view; meal contracts with food.view. Refused without any of those five. A section that fails is null and named in errors. Visa, tickets and suppliers come from badge_counts, the call Home makes |
The phone dashboard and departure readiness
Migrations 20261001150000_the_phone_dashboard.sql and
20261001180000_the_phone_dashboard_follows_the_web.sql, for the native app
(The Alhuda Travels app → Dashboard).
| Screen | Function |
|---|---|
| Staff → Dashboard tab | dashboard_screen(profile, scope, main) — the website's call (PRF-002), one per tab, with the profile the phone detected from the shared role profiles. The phone's own app_dashboard() and app_dashboard_money() are gone (dropped by 20261001180000): nothing called them any more |
| Staff → a departure's readiness | app_departure_readiness(group) — SECURITY DEFINER with the permission set of dash_departures. The group totals are dash_group_readiness (the same % as the home card); per booking finance clearance (booking_finance_cleared) and the amount due; per traveller visa, ticket and room with the same tests as dash_group_readiness |
The two figures the phone used to add on its own now come from the functions both the website
and the phone read, under their own permissions: dash_collections_month() returns today
(receipts verified today, India time) and dash_receivables_overdue() returns ledger (the
party-ledger total split into customer CUS- and partner AGR- ledgers).
On the readiness screen amounts need finance.view or bookings.view and phone numbers
bookings.view or customers.view; without them the database returns null for those fields.
supabase/tests/the_phone_dashboard.sql checks the leadership tab against the functions it
wraps, today's collections and the ledger split, and readiness for a manager, a login with
groups.view only and a login with no permissions.