RoadieRoadie
  • Home
  • Brand
  • Foundations
  • Content
  • Tokens
  • Components
  • Charts
  • Widgets
  • Appearance
HomeBrandFoundations
Appearance

Foundations

  • Visual

    • Colors
    • Elevation
    • Iconography
    • Shape
    • Theming
  • Content

    • Date and time
    • Layout
    • Prose styles
    • Typography
  • Behaviour

    • Accessibility
    • Interactions
    • Motion
    • View transitions
  • Building apps

    • Forms
    • Linking
    • Navigation
    • Performance
    • Records modelAlpha
    • Tables

Records model

Status
Alpha
Added
Components 2.16.020 Oct 2026
Updated
Components 2.17.024 Oct 2026
Docs updated
21 Oct 2026

Organisers work through lists: events, sessions, orders, attendees, and customers. @oztix/roadie-core/records describes those lists once, as plain TypeScript with no components, so a search typed on a list screen means the same thing in the browser, on your server, in Meilisearch or Elasticsearch, and in a shared link. RecordTable and RecordGrid build on it.

Fields

A field describes one fact about a record: its key, its label, its type, and how it may be filtered, sorted, and searched. Keep one field list per entity and share it between screens. Every per-row fact is a key, never a function, so the same list works in a browser, on a server, and in an adapter. Field lists are code, and views are the JSON.

import type { RecordField } from '@oztix/roadie-core/records'
export const eventFields: RecordField[] = [
{ key: 'name', label: 'Name', type: 'text' },
{ key: 'orderNumber', label: 'Order number', type: 'text', match: '^OZ-\\d{5}$' },
{
PropertyMeaning
typetext, option, location, number, money, date, or boolean. It decides which operators fit.
filterableDefaults to true. Set false to keep a field out of filters and suggestions.
sortableDefaults to true.
searchableFree-text search reads it. Defaults to true for text and false otherwise.
multipleThe row holds a list. Is means has any of, and is not means has none of. An option field also gets has all. A list of dates can't be filtered yet.
endFor a date range, the key holding the end. Date filters then test overlap.
momentHow a date compares: event, access, timestamp (the default), or date. See Dates.
timeZoneKeyFor event and access moments, the row key holding the venue's IANA zone.
localDateKey, endLocalDateKeyThe stored venue-local date, for adapters that can't work it out per row.
matchA pattern for identifiers, such as order numbers, as a string anchored with ^ and $. Typed text that matches, in any case, is offered as an exact filter.
formatHow a number or money field's values read. Defaults to number, or currency for money.
currency, currencyKeyA fixed ISO code, or the row key holding it.
statusThe dashboard status map. An option field with one gets Badge rendering, sorting by order, and labels from one definition.
optionsThe values to pick from, each with an optional parent for hierarchies and, on a location, a kind.

Places

A location field holds a place, such as a suburb, or a list of places. Its options are places with stable ids, never free text, and each names the place it's in as its parent and what it is as its kind: venue, suburb, city, region, state, country, or any other. So is and is not read is in and is not in. A record is in its own place and every place that contains it, so with a record in Fitzroy, "Location is in Victoria" matches and "Location is not in Melbourne" doesn't. Suggestions say where each place is, such as "Suburb in Melbourne" or "Country".

  • A parent the options don't hold ends the line, so that place is top level.
  • A value the options don't hold is in only itself.
  • A location field without options picks from the values its records hold, each in only itself.
  • Is empty and is not empty work as they do on any field.

Distance filters come later as a near operator, never within, which dates use. Its value will be { lat, lng, meters, label }, and a field option will name the key holding the record's coordinates.

Derived states, such as Selling fast or Sold out, are worked out once on the server and arrive as option values. Roadie never bands a percentage into a state.

In React, useRecords takes RecordsField from @oztix/roadie-components/records: a field with an optional icon, a bold Phosphor icon. Records.Search shows it on the field's suggestions and their filters, never on values or chips. A field without one gets its type's icon: a tag for an option, a half circle for an option with status, a map pin for a location, Aa for text, a hash for a number, a dollar sign for money, a calendar for a date, and a toggle for a boolean. Give a field that names a kind of thing its own, such as a building for a venue.

Views

A view is a search, filters, sort, and layout, saved under a name. Each filter is a chip. Chips are ANDed, and the values inside one chip are ORed. Query and layout are independent, so the same search reads as a table or a grid of cards, and Configure switches between them. Page, scroll row, and selection are session state, never part of a view.

{
"version": 1,
"id": "melbourne-weekend",
"name": "Melbourne this weekend",
"entity": "events",
"query": {
Field typeOperators
Textcontains, not-contains, is, is-not
Optionis, is-not, and has-all when multiple
Number, moneyeq, neq, lt, gt, between
Dateon, before, after, between, within
Booleanis-true, is-false
Anyis-set, is-not-set

Empty is a real state: null, an empty string, and an empty list are all not set. Negative operators (is-not, not-contains, and neq) keep records where the field is empty, the way Meilisearch does. Check a view from storage or an API with validateRecordView(view, fields). It rejects unknown fields and operators that don't fit, and says what would. A view carries its format as version, RECORD_VIEW_VERSION. A view saved without one reads as version 1, and a version this release doesn't know is rejected.

Scope

Scope is the part of a query the page sets, such as "products offered on Lampshade Disco, any of its days, or its collection". It's a filter list your app resolves, because it can span levels a flat record can't express. Scope is never saved in a view, and Clear never removes it. Pass it to RecordTable, RecordGrid, or useRecords as scope. In server mode, fetch the request onRequestChange hands you, or records.request from useRecords. It puts the scope before the view's own filters.

Dates

Date filters follow the same moment kinds as date and time formatting. Calendar words resolve in the viewer's zone, so "today" is today's date at every venue. An event at 11:30pm in Perth is still a Saturday gig when it's already Sunday in Sydney.

momentExampleFilters compare
eventSession start, doorsVenue-local calendar date, never converted
accessOn sale, presaleVenue-local date
timestampOrder placedThe viewer's day, as instants
dateBirthday, all-day on-saleA plain date with no zone

Relative ranges have one fixed meaning: a week is Monday to Sunday, a weekend is Saturday and Sunday, rolling windows count today, and the financial year starts in July. The core functions take weekStart and fiscalYearStart, but the Records components always use these defaults, so keep them wherever a view also runs in the browser.

Each range has one form: the last, this, or next day, week, month, quarter, or year is a word, such as this-week or last-quarter, and any other period is an object, such as { period: 'month', offset: -2 }. Two views that mean the same then compare equal.

Hour windows and the open ranges upcoming, past, and ongoing compare the instant, except on a plain date, where upcoming starts today and past ends yesterday. A range field matches when any part of it overlaps, so a five-night festival is on this weekend if one night is.

A time written without an offset names one moment, in the record's or the viewer's zone. When daylight saving repeats an hour it's the first pass, and when it skips one it's the moment the clocks jump. Records and filters read times the same way, so a single moment is always before, on, or after a date or time, never two of these.

Browser and server

resolveRecordQuery(query, fields, { now, timeZone }) fixes relative dates at a moment, and every place a query runs reads the result the same way. A server reads the request the browser sends, with its dates already fixed, never the view, so it needs no clock. Pick where it runs by where the records live: in the browser, a Meilisearch index, an Elasticsearch index, or your own API.

  • Browser

    matchesRecordQuery(row, resolved, fields)

    Every row is already loaded, up to a few thousand.

  • Meilisearch

    toMeilisearch(request, fields)

    A Meilisearch index, with typo-tolerant search.

  • Elasticsearch

    toElasticsearch(request, fields)

    An Elasticsearch or OpenSearch index that already serves a list.

  • Your API

    POST /api/…/search

    Any other store. The server reads the request and maps it itself.

Each index has a translator on its own subpath, so the model stays backend-agnostic. On the docs site, the request for the view from Views, at 11:30pm on a Friday in Melbourne, runs through each one, and the result shows as comments under the call.

toMeilisearch(request, fields) from @oztix/roadie-core/records/meilisearch returns Meilisearch's q, filter, and sort, and the page.

const request = toRecordsRequest(view.query, eventFields, {
now: new Date('2026-10-02T13:30:00Z'), // 11:30pm Friday in Melbourne
timeZone: 'Australia/Melbourne'
}, { page: 0, pageSize: 50 })
toMeilisearch(request, eventFields)
// {
Index settingWhat it needs
VersionMeilisearch 1.15 or later, which compares date strings
InstantsEpoch seconds, or epoch: 'milliseconds'
Event and access datesThe venue-local date under localDateKey
RangesThe end on every record
SearchsearchableAttributes lists the fields marked searchable
Text containsThe containsFilter experimental feature
Pages and counts

A request's page and pageSize become page (one-based) and hitsPerPage, so the response's totalHits counts the matches, up to the index's pagination.maxTotalHits. A RecordTable takes that count as rowCount and the page as its data. A range, start to end, becomes a page the same way when it is a whole page, as the ranges a list asks for are, and offset and limit otherwise, where Meilisearch only estimates the count.

Permissions

Scope facet value search to the person's permissions on the server, as well as the search itself.

toElasticsearch(request, fields) from @oztix/roadie-core/records/elasticsearch returns an Elasticsearch search body: query, sort, from, and size. Each chip is one clause in bool.filter, and search is a simple_query_string that needs every word.

const request = toRecordsRequest(view.query, eventFields, {
now: new Date('2026-10-02T13:30:00Z'), // 11:30pm Friday in Melbourne
timeZone: 'Australia/Melbourne'
}, { page: 0, pageSize: 50 })
toElasticsearch(request, eventFields)
// {
Index settingWhat it needs
VersionElasticsearch 7.10 or later, or OpenSearch, for case-insensitive terms
Filters and sortkeyword fields, or a keyword sub-field named in paths, such as { venue: 'venue.raw' }
Searchtext fields: those marked searchable, or searchFields with boosts, such as ['name^5', 'venue']
Instantsdate
Event and access datesThe venue-local date or date-time under localDateKey, as a date with no zone
RangesThe end on every record
Pages and counts

Post the body to your search endpoint, which adds the person's permissions as a filter of its own, and add highlight and track_total_hits as the page needs. The response's hits.total.value is rowCount. It's exact, for rowCountExact, when hits.total.relation is eq, and Elasticsearch counts to 10,000 unless track_total_hits says otherwise. With no paging, the body has no size, so set one for an export.

Counts beside each filter value

A checkbox list with a count beside each value counts that field against every other filter, so ticking one venue doesn't hide the rest. Pass the field as except, which leaves out its own chips, and add the aggregation:

const { query } = toElasticsearch(request, eventFields, { except: 'venue' })
const body = {
query,
size: 0,
aggs: { venue: { terms: { field: 'venue', size: 50 } } }
}
Dates and search

A venue-local date-time under localDateKey, such as 2026-10-03T19:30:00, counts as its whole day, so an index that already stores one needs no new field. Search matches analysed words, so a searchable field mapped only as keyword matches only its whole value: map it as text, with its keyword sub-field in paths.

Places on any backend

A filter on a place stays a plain term, location:is:au-vic, in the URL and in every backend. Store each record's places once, when you index it: recordPlaceReader(field) returns a reader that lists a record's place and every place that contains it. An indexer calls it per record, so make one per field, and it reads the options once. recordPlaces(field, value) does the same for one value. In the browser, matchesRecordQuery reads them from the options itself.

import { recordPlaceReader } from '@oztix/roadie-core/records'
const placesOf = recordPlaceReader(locationField)
const documents = venues.map((venue) => ({
...venue,
location: placesOf(venue.location)

An index can store that list already, as a record's value, and the field takes it too. A filter then reads the same everywhere:

BackendLocation is in Victoria
Meilisearchlocation = "au-vic", from toMeilisearch
AlgoliafacetFilters: [["location:au-vic"]]
Elasticsearch{ term: { location: … } }, from toElasticsearch
An API?location=au-vic, matched against the list

A filter holding several places ORs them: Algolia's inner array, or Elasticsearch's should. Is not in negates the same term: Meilisearch's location != "au-vic", Algolia's "location:-au-vic", or the term in a must_not. Each keeps records with no place, as Roadie does. Algolia needs location in attributesForFaceting, and Elasticsearch needs it mapped as a keyword. Listing every place inside the one picked would be thousands of suburbs for a state, too many for a query, so the places travel with the record instead.

Distance filters, when they come, read the record's coordinates with Meilisearch's _geoRadius(lat, lng, meters) and sort by _geoPoint, Algolia's aroundLatLng and aroundRadius, or Elasticsearch's geo_distance on a geo_point.

Server contract

A server never reads a view. It reads a RecordsRequest: the view's query resolved at a moment, with the page's scope first, under a version. Relative dates arrive fixed, so the server needs no calendar rules, and "this weekend" means the same there as in the browser. onRequestChange hands it over, useRecords holds it as records.request, every action gets it as request, and toRecordsRequest(query, fields, { now, timeZone, scope }, paging) builds one anywhere else. POST it as JSON. The view from Views, on its first page at 11:30pm on a Friday in Melbourne, is this request:

{
"version": 1,
"query": {
"search": "lampshade",
"filters": [
{
KeyHolds
version1, RECORDS_REQUEST_VERSION. Reject a version the server doesn't know. It changes only when an existing key's meaning does.
query.searchThe search text, for the fields marked searchable.
query.filtersThe chips in order, ANDed. A date filter becomes overlaps with a range: dates compares calendar dates, the venue's for event and access fields, and instants compares epoch milliseconds. Ends are inclusive and null is open. Every other filter is as the view holds it.
query.sortThe sort, in order.
query.timeZoneThe viewer's zone, for rows that name no venue zone.
page, pageSizeOne page, counted from 0, in a paged list.
start, endRows from start up to, not including, end, in a list loaded by range.

A request with no paging means every record that matches, as for an export or an action on all of them. Match as the browser does: text compares ignoring case, for is, is-not, has-all, contains, and not-contains, and negative operators keep records where the field is empty. Field keys are logical names, never index paths: the server's adapter maps each one, such as location to venue.city, so an index can change without changing a saved view, a link, or a request.

Answer with the page's rows and the count, rowCount. An index that stops counting, such as one that counts to 10,000, also answers whether the count is exact, for rowCountExact. Counts then read "10,000+", and paging stops at the last page the index can return.

@oztix/roadie-core/records/records-request.schema.json and @oztix/roadie-core/records/record-view.schema.json are JSON Schemas, draft 07, for a request and a stored view. They're generated from the schemas validateRecordView uses, so they always agree with it. Generate a server's models from them, or check what arrives against them. @oztix/roadie-core/records/json-schema returns the same schemas in JavaScript, from recordsRequestJsonSchema() and recordViewJsonSchema().

Ignore keys you don't know. A later release can add one at the top of a request or in its query and still send version 1, and the request schema accepts them there. A filter or a sort with a key the schema doesn't name is rejected, since it would change what matches.

So is a filter operator the schema doesn't name, and a version 1 server rejects it. A new operator, such as near for distance, comes with request version 2, or only on fields the server opts in to, never quietly in version 1.

On a .NET server, generate the models from the schema files with a tool such as NJsonSchema, or write them as records. ASP.NET Core reads the camel case keys into them. Apply the person's permissions on the server as a filter of your own, ANDed with the request's, so the browser can never widen what it sees.

public sealed record RecordsRequest(
int Version,
RecordsQuery Query,
int? Page = null,
int? PageSize = null,
int? Start = null,

EventSearch stands for your adapter: it maps each field key to the index, turns each filter into the index's query, and adds the permission filter to every search, count, export, and bulk action.

Typed text

parseRecordQuery(text, { fields, now, timeZone }) reads typed text into ranked suggestions, shaped for QueryField. It knows nothing about any one entity. To search events and orders together, call it once per entity with its entity and merge the results by score. It reads identifiers, field:value, field names, option and status values, booleans by name, and date phrases. Each part of the text gets its best reading first, with the rest kept as remainder. A phrase always becomes a visible, editable chip, never hidden logic. On the docs site, the suggestions for "melb this weekend" show as comments under the call, with each one's kind, label, and remainder.

parseRecordQuery('melb this weekend', { fields: eventFields, now, timeZone })
// [
// {
// "kind": "filter",
// "label": "Start date is this weekend",
// "remainder": "melb"

Values read from the start of a word, so melb suggests "Location is in Melbourne" and elb suggests nothing. A value named only in part is left out when a longer reading takes its words: in melb this weekend, "this" belongs to "Start date is this weekend", so it never suggests a venue such as Nimbus Thistle Hall. Text a field contains takes no words this way, so name disco melb still suggests "Location is in Melbourne".

Negation reads as the opposite filter. "not melb" suggests "Location is not in Melbourne", and never "Location is in Melbourne". The negating words are part of the filter, so none of them stay in remainder.

FormTypedSuggests
not, isn't, excludes, excluding, except, without, !=, or ≠ before a valueexcluding walrusVenue is not The Quilted Walrus Room
- before a value-sold outStatus is not Sold out
!, !=, or - after field:venue:!walrusVenue is not The Quilted Walrus Room
<field> is not, or <field> then any word in the first rowlocation not sydLocation is not in Sydney
<field> doesn't contain or <field> does not containname doesn't contain discoName doesn't contain "disco"

The apostrophe is optional, so location isnt syd suggests "Location is not in Sydney".

venue is walrus, venue = walrus, and name contains disco read the same way, as the filter without the negation. Once a value is negated, its positive is never suggested, so "status is not sold out" never offers "Status is Sold out".

  • A - before a number is a minus sign, so capacity:-5 reads as "Capacity is -5".
  • A date or a comparison, such as more than, has no opposite. A negated one reads nothing, and neither do its words, so not this weekend never suggests a venue such as Nimbus Thistle Hall.

A number or money field's name, then a comparison, reads as that filter. field:value takes the same words, so capacity:over 100 and capacity:20 and 50 work too.

FormTypedSuggests
>, more than, or overcapacity > 100Capacity is more than 100
<, less than, or undercapacity under 50Capacity is less than 50
= or iscapacity = 100Capacity is 100
!= or ≠capacity != 100Capacity is not 100
between and and, or a range with a -capacity between 20 and 50Capacity is 20 to 50

A range reads in either order, so capacity 50-20 suggests "Capacity is 20 to 50".

  • There's no at least or at most, so >=, <=, ≥, ≤, at least, at most, and 100+ read nothing and capacity >= 100 stays search text. The field's name still reads as the field, so with a Sold field, sold >= 100 never suggests "Status is Sold out".
  • Numbers a space separates stay apart, so capacity > 5 6 suggests "Capacity is more than 5" and keeps 6 as search text.

Any field's name, then a value, reads as field:value does, with or without is. Text a field contains stops before words other filters read to the end, so name disco melb suggests "Name contains “disco”" and "Location is in Melbourne" first, then "Name contains “disco melb”".

FormTypedSuggests
<field> <value>, with no verblocation melbLocation is in Melbourne
<field> <value>, with no verbcapacity 100Capacity is 100
<field> <value>, with no verbstarts this weekendStart date is this weekend
<field> is emptystarts is emptyStart date is empty
<field> is not emptylocation is not emptyLocation is not empty

A date keeps the word that compares it. starts after today suggests Start date after today's date, never "Start date is today", and after, before, since, from, until, till, on or after, and on or before read as the date phrases do. One that can't be a filter, such as after 7pm, suggests no date. The words of a longer date read nothing on their own, so last monday never also suggests the coming Monday, and 20 to 23 oct never suggests the 23rd alone. A range of two days reads whole or not at all, so tomorrow to yesterday suggests no date. Each of its days can still be a value, so with a Day field, saturday and sunday suggests "Day is Saturday" after the dates. Words that lead into a date, such as on, the, and in the, are part of it, so on the weekend never suggests "Status is On sale", and the 20th of oct never suggests a venue whose name starts with The.

A field name a statement reads is never also read as a value. With a Sold field, sold > 100 suggests "Sold is more than 100" and never "Status is Sold out". A statement comes before any other reading of its value that leaves the field's name out, so day saturday and sunday suggests "Day is Saturday" before the dates.

describeRecordFilter(filter, fields, { now, timeZone }) writes any filter as its chip reads, such as "Gross is more than $100", with the dates a relative range stands for in detail. parseRecordQuery labels its filters the same way and takes each description from that detail, so a suggestion and the chip it makes read alike, dates included. Next and last weekend, whose chips have no detail, are described as "Next weekend" and "Last weekend". Records.Search uses both, and shows a page's scope as locked chips that are never part of the view. Text that names a filter already applied, in the view or the scope, still suggests it, marked Already applied. Taking it clears those words and adds nothing, so with "Location is in Melbourne" applied, melb syd can take both words.

URL format

recordViewToSearchParams(view, position) writes a view as search params, so a list screen opens from a link with the view applied. The format is versioned, as RECORD_URL_VERSION, and a public contract: the same view always writes the same URL. recordViewFromSearchParams(params, fields, { fallback }) reads it back, ignores keys it doesn't know, and returns your fallback view with its problems when the URL holds something invalid.

KeyHolds
vThe URL format, RECORD_URL_VERSION, 1
view, entityThe saved view it started from, and the entity. A URL view has no name
qThe search text
fOne per chip, in order, as field:operator:value. Lists are comma separated
sortFields in order, descending with a leading dash, such as -starts,name
layout, columns, hidden, fieldsThe layout and its settings
page, size, rowWhere the reader is: the page, or in a list loaded by range, the first row on screen. The URL counts pages from 1, and position.page counts from 0

On the docs site, the view from Views on its first page shows as the params recordViewToSearchParams writes, one a line.

v=1
view=melbourne-weekend
entity=events
q=lampshade
f=location:is:au-vic-melbourne
f=starts:within:this-weekend

Modified views

equalRecordViews(view, baseline) tells whether a view still matches the one it was opened from, for the modified mark beside a view name. It ignores id, name, spacing in the search, the order of chips and their values, and how a relative range is written. Sort and column order still count.

Pass that saved view to useRecords as baseline and it gives modified and resetView. Records.ViewActions shows the name and the mark, with a menu to save, save as, rename, reset, or delete the view. Your app keeps the presets and the views people save, and lists them, as RecordTable shows.

Guidelines

Describe per-row facts as keys

A function cannot travel to a server, a URL, or a search index.

{ key: 'starts', type: 'date', moment: 'event', timeZoneKey: 'venueZone' }

Do

Name the key that holds the venue's zone.

{ key: 'starts', type: 'date', zone: (row) => row.venue.zone }

Don’t

Work it out in a callback only the browser can run.

Keep session state out of views

A saved view should open the same for everyone, at the top.

recordViewToSearchParams(view, { page: 2, row: 40 })

Do

Pass the page and row as position, next to the view.

saveView({ ...view, page: 2, selected: ['ord_123'] })

Don’t

Save where one person had scrolled to as part of the view.

Previous page← PerformanceNext pageTables →