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.
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.
| Property | Meaning |
|---|---|
type | text, option, location, number, money, date, or boolean. It decides which operators fit. |
filterable | Defaults to true. Set false to keep a field out of filters and suggestions. |
sortable | Defaults to true. |
searchable | Free-text search reads it. Defaults to true for text and false otherwise. |
multiple | The 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. |
end | For a date range, the key holding the end. Date filters then test overlap. |
moment | How a date compares: event, access, timestamp (the default), or date. See Dates. |
timeZoneKey | For event and access moments, the row key holding the venue's IANA zone. |
localDateKey, endLocalDateKey | The stored venue-local date, for adapters that can't work it out per row. |
match | A 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. |
format | How a number or money field's values read. Defaults to number, or currency for money. |
currency, currencyKey | A fixed ISO code, or the row key holding it. |
status | The dashboard status map. An option field with one gets Badge rendering, sorting by order, and labels from one definition. |
options | The values to pick from, each with an optional parent for hierarchies and, on a location, a kind. |
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".
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.
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.
| Field type | Operators |
|---|---|
| Text | contains, not-contains, is, is-not |
| Option | is, is-not, and has-all when multiple |
| Number, money | eq, neq, lt, gt, between |
| Date | on, before, after, between, within |
| Boolean | is-true, is-false |
| Any | is-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 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.
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.
| moment | Example | Filters compare |
|---|---|---|
| event | Session start, doors | Venue-local calendar date, never converted |
| access | On sale, presale | Venue-local date |
| timestamp | Order placed | The viewer's day, as instants |
| date | Birthday, all-day on-sale | A 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.
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/…/searchAny 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.
| Index setting | What it needs |
|---|---|
| Version | Meilisearch 1.15 or later, which compares date strings |
| Instants | Epoch seconds, or epoch: 'milliseconds' |
| Event and access dates | The venue-local date under localDateKey |
| Ranges | The end on every record |
| Search | searchableAttributes lists the fields marked searchable |
Text contains | The containsFilter experimental feature |
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.
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.
| Index setting | What it needs |
|---|---|
| Version | Elasticsearch 7.10 or later, or OpenSearch, for case-insensitive terms |
| Filters and sort | keyword fields, or a keyword sub-field named in paths, such as { venue: 'venue.raw' } |
| Search | text fields: those marked searchable, or searchFields with boosts, such as ['name^5', 'venue'] |
| Instants | date |
| Event and access dates | The venue-local date or date-time under localDateKey, as a date with no zone |
| Ranges | The end on every record |
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.
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:
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.
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.
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:
| Backend | Location is in Victoria |
|---|---|
| Meilisearch | location = "au-vic", from toMeilisearch |
| Algolia | facetFilters: [["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.
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:
| Key | Holds |
|---|---|
version | 1, RECORDS_REQUEST_VERSION. Reject a version the server doesn't know. It changes only when an existing key's meaning does. |
query.search | The search text, for the fields marked searchable. |
query.filters | The 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.sort | The sort, in order. |
query.timeZone | The viewer's zone, for rows that name no venue zone. |
page, pageSize | One page, counted from 0, in a paged list. |
start, end | Rows 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.
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.
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.
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.
| Form | Typed | Suggests |
|---|---|---|
not, isn't, excludes, excluding, except, without, !=, or ≠ before a value | excluding walrus | Venue is not The Quilted Walrus Room |
- before a value | -sold out | Status is not Sold out |
!, !=, or - after field: | venue:!walrus | Venue is not The Quilted Walrus Room |
<field> is not, or <field> then any word in the first row | location not syd | Location is not in Sydney |
<field> doesn't contain or <field> does not contain | name doesn't contain disco | Name 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".
- before a number is a minus sign, so capacity:-5 reads as "Capacity
is -5".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.
| Form | Typed | Suggests |
|---|---|---|
>, more than, or over | capacity > 100 | Capacity is more than 100 |
<, less than, or under | capacity under 50 | Capacity is less than 50 |
= or is | capacity = 100 | Capacity is 100 |
!= or ≠ | capacity != 100 | Capacity is not 100 |
between and and, or a range with a - | capacity between 20 and 50 | Capacity is 20 to 50 |
A range reads in either order, so capacity 50-20 suggests "Capacity is 20 to
50".
>=, <=, ≥, ≤, 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".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”".
| Form | Typed | Suggests |
|---|---|---|
<field> <value>, with no verb | location melb | Location is in Melbourne |
<field> <value>, with no verb | capacity 100 | Capacity is 100 |
<field> <value>, with no verb | starts this weekend | Start date is this weekend |
<field> is empty | starts is empty | Start date is empty |
<field> is not empty | location is not empty | Location 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.
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.
| Key | Holds |
|---|---|
v | The URL format, RECORD_URL_VERSION, 1 |
view, entity | The saved view it started from, and the entity. A URL view has no name |
q | The search text |
f | One per chip, in order, as field:operator:value. Lists are comma separated |
sort | Fields in order, descending with a leading dash, such as -starts,name |
layout, columns, hidden, fields | The layout and its settings |
page, size, row | Where 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.
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.
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.
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.