Skip to content

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 of booking_page, and for each row the customer, group, partner, payer, quotation, passengers and payments, the approvers' names, the next cursor and the total (exact with p_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; with p_sections also 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 by bookingId was partial.
  • JournalEntry("referenceId", "createdAt" DESC) and Payment("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.ts falls 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.