Example
One complete file, then the walk-through. This is the analytics.json of a hypothetical acme/warehouse App whose schema.json owns a locations entity and a zones entity.
{
"$schema": "https://schemas.revenexx.com/analytics.schema.json",
"version": "1",
"views": [
{
"name": "locations",
"source": "locations",
"key": "id",
"title": "Stock locations",
"description": "One row per location, with its type and market.",
"depends_on": ["zones"],
"roles": {
"time": "created_at",
"entity": "location_id",
"measures": ["capacity"],
"dimensions": ["type", "zone"]
},
"columns": [
{ "name": "location_id", "from": "column:id", "type": "uuid", "optional": false },
{ "name": "code", "from": "column:code", "type": "text", "optional": false },
{ "name": "type", "from": "column:type", "type": "text", "optional": false },
{ "name": "capacity", "from": "column:capacity", "type": "numeric" },
{ "name": "label", "from": "attribute:locale.display_name", "type": "text" },
{ "name": "zone", "from": "relation:zone_id→zones.code", "type": "text" },
{ "name": "created_at", "from": "column:created_at", "type": "timestamptz", "optional": false }
]
}
]
}
The envelope
"$schema": "https://schemas.revenexx.com/analytics.schema.json",
"version": "1",
$schema is not required, and you want it anyway: it gives your editor completion and validation, which catches a mistyped locator before a deploy does. version is the contract major version — "1" today, and the only accepted value.
views takes at least one view. This app ships one.
Identity
"name": "locations",
"source": "locations",
"key": "id",
source names the entity from schema.json. name is the view's own name, and it need not match the source — but where it can, it should, because the name becomes part of the public dataset identifier:
analytics.acme__warehouse__locations
key is id here, which is also the default. It is declared explicitly because being explicit about the key of a view is worth one line.
Labels
"title": "Stock locations",
"description": "One row per location, with its type and market.",
This is what the operator reads in the dataset catalog. Without a title the name is humanised — "Locations" — which is worse than "Stock locations" and much worse than a description that states the grain.
State the grain in the description. "One row per location" is the single most useful sentence in the file for whoever charts it.
Dependencies
"depends_on": ["zones"],
The view reads zones through the relation locator below. depends_on documents that. It is not a join specification — the locator does the joining — and it is what a reviewer reads to see what the view touches.
The columns
{ "name": "location_id", "from": "column:id", "type": "uuid", "optional": false },
The key, exposed under a name that is meaningful in a chart legend. optional: false because it can never be null. Note the rename: the column in the entity is id, and in the view it is location_id — worth doing, because id in a dataset picker tells nobody which id it is.
{ "name": "code", "from": "column:code", "type": "text", "optional": false },
{ "name": "type", "from": "column:type", "type": "text", "optional": false },
Two plain scalar columns. type will be a dimension, which is why it is cast to text and marked non-null: a dimension with nulls in it produces a chart with an unexplained bucket.
{ "name": "capacity", "from": "column:capacity", "type": "numeric" },
The one measure. Cast to numeric deliberately — a measure that arrives as text cannot be summed, and no error will tell you. Left optional, because a location without a declared capacity is legitimate.
{ "name": "label", "from": "attribute:locale.display_name", "type": "text" },
A locale-scoped attribute, lifted out of its bucket and resolved at the view's default_locale — which is not declared here, so en. One row per key, not one per locale: if this app needed German and English side by side, that would be two explicit columns, not a second row.
{ "name": "zone", "from": "relation:zone_id→zones.code", "type": "text" },
The relation. The zone_id foreign key is followed to one row in zones, and that row's code is taken. The arrow is a literal → — a -> here is a validation failure.
This is the line that makes the dataset usable. Without it the only zone information in the view is a UUID, and nobody groups a chart by UUIDs.
{ "name": "created_at", "from": "column:created_at", "type": "timestamptz", "optional": false }
The time axis, cast to timestamptz and non-null, because the time role points at it and a null there means a row that silently drops out of every date-scoped view.
The roles
"roles": {
"time": "created_at",
"entity": "location_id",
"measures": ["capacity"],
"dimensions": ["type", "zone"]
}
Read it back as a sentence: sum of capacity, by type and zone, over created_at, per location. That is a question somebody would actually ask, which is the test for whether the roles are right.
Every name here refers to a column declared above — that is a rule, not a convention. And note what is not in the roles: code and label are in the view because an operator needs to recognise a row, not because anything should be grouped by them. A high-cardinality label is a bad dimension.
What you would add next
For a real warehouse app, one view is not enough. The natural second and third:
movements— one row per stock movement,timeon the movement timestamp,entityon the product,measureson the quantity. A different grain, so a different view.stock_levels— one row per product per location, with the current level as the measure and no time role at all, because it is a snapshot rather than a series.
Three narrow views beat one wide one: the roles can be right for each, and the grain is stated rather than implied.
Where to go next
- The contract — the file's place in the App, and what deploy does with it.
- Column locators — the locators this example does not use.
- Tenant isolation — what to leave out of a view.
- Deploy with the CLI — shipping it.