Skip to main content

Datasets

Datasets give Radish apps a built-in analytics aggregation layer for data that lives outside the app — in a data warehouse, a sibling MongoDB collection, or an HTTP API. Enable the feature, define your datasets as data (not code), and query rollups, trends, and rankings through a typed service API.

This is deliberately not a general-purpose BI tool. Datasets solve a specific problem: your app needs to show charts, KPIs, and leaderboards derived from an upstream source, without coupling your frontend to that source's query language or connection details.

When to Use Datasets​

Use caseDatasets?Why
Dashboard showing sales by category from a data warehouse✅ YesClassic rollup over remote data
Time-series chart of order volume by month✅ YesTrend query with grain bucketing
"Top 10 customers by revenue" widget✅ YesTopN ranking
Full-text search across products❌ NoUse Search instead
Counting records in your own entities❌ NoUse the entity service's count() method
Real-time event stream processing❌ NoDatasets sync periodically, not in real time

Quick Start​

1. Enable the feature​

Add datasets to your app blueprint's features:

{
"features": {
"datasets": true
}
}

Or pass it on the command line:

radish-cli create datalayer . --schema blueprints/app.types.json --features datasets

2. Generate​

radish-cli create datalayer . --schema blueprints/app.types.json

This generates three entities (DatasetDefinition, DatasetField, DatasetRow), two services (DatasetService, DatasetSyncService), and the corresponding API routes.

3. Create a dataset definition​

import { DatasetDefinitionService, systemAuth } from '@generated/datalayer/services';

const svc = new DatasetDefinitionService(systemAuth());
await svc.create({
datasetName: 'invoiceActivity',
kind: 'fact',
source: 'databricks',
sourceRef: 'analytics.commerce.invoice_activity_v2',
syncMode: 'scheduled',
syncSchedule: '0 6 * * *',
grainColumns: ['invoice_id', 'line_num', 'shipment_skey'],
enabled: true,
});

4. Register field metadata​

import { DatasetFieldService, systemAuth } from '@generated/datalayer/services';

const svc = new DatasetFieldService(systemAuth());

// Dimensions — groupable columns
await svc.create({ datasetId: defId, columnName: 'marketCategory', label: 'Market Category', role: 'dimension', dataType: 'string', provenance: 'curated' });
await svc.create({ datasetId: defId, columnName: 'invoiceDate', label: 'Invoice Date', role: 'dimension', dataType: 'date', grain: 'day', provenance: 'curated' });

// Measures — aggregatable values
await svc.create({ datasetId: defId, columnName: 'netSales', label: 'Net Sales', role: 'measure', dataType: 'float', agg: 'sum', provenance: 'curated' });
await svc.create({ datasetId: defId, columnName: 'cogs', label: 'COGS', role: 'measure', dataType: 'float', agg: 'sum', provenance: 'curated' });
await svc.create({ datasetId: defId, columnName: 'avgDays', label: 'Avg Days', role: 'measure', dataType: 'float', agg: 'avg', provenance: 'curated' });

5. Sync data from the source​

import { syncDataset } from '@generated/datalayer/services/dataset-sync.service';

const result = await syncDataset('invoiceActivity', async (ctx) => {
// Your source-specific fetch logic
return fetchFromDatabricks(client, ctx.definition.sourceRef);
}, {
grainColumns: ['invoice_id', 'line_num', 'shipment_skey'],
});

console.log(`Synced ${result.inserted} inserted, ${result.updated} updated`);

6. Query aggregations​

import { DatasetService, systemAuth } from '@generated/datalayer/services';

const svc = new DatasetService(systemAuth());

// Rollup: sales by category
const rollup = await svc.rollup({
dataset: 'invoiceActivity',
by: ['marketCategory'],
measures: ['netSales', 'cogs'],
where: { bucketDate: { $gte: '2026-06-01', $lte: '2026-08-31' } },
});

// Trend: monthly sales
const trend = await svc.trend({
dataset: 'invoiceActivity',
grain: 'month',
measures: ['netSales'],
});

// Top N: biggest customers
const topN = await svc.topN({
dataset: 'invoiceActivity',
by: 'customerId',
measure: 'netSales',
n: 25,
});

Architecture​

Three Entities​

Datasets use three entities that share one MongoDB collection pattern:

EntityPurposeKey fields
DatasetDefinitionWhat to sync, where from, how oftendatasetName, source, sourceRef, grainColumns, syncSchedule
DatasetFieldPer-column metadata: role, type, aggregationdatasetId, columnName, role, agg, provenance
DatasetRowThe actual data — dimensions + measures per graindatasetId, grainKey, bucketDate, dimensions, measures

Two Services​

ServiceResponsibilityAuth model
DatasetServiceRead path — rollup(), trend(), topN()Requires datasetrow:view:all permission
DatasetSyncServiceWrite path — syncDataset() functionUses systemAuth() (no user context)

Data Flow​

┌──────────────────┐       ┌──────────────────┐      ┌───────────────┐
│ Upstream Source │──────▶│ DatasetSync │─────▶│ DatasetRow │
│ (Databricks, │ fetch │ Service │ map │ Collection │
│ Mongo, HTTP) │ │ ┌─field plan─┐ │ + │ │
└──────────────────┘ │ │ dims: [...]│ │ upsert└───────┬───────┘
│ │ measures:[]│ │ │
│ │ grain: ... │ │ │ aggregate
│ └────────────┘ │ │ pipeline
└──────────────────┘ ┌───────▼───────┐
│ DatasetService│
│ rollup() │
│ trend() │
│ topN() │
└───────────────┘

DatasetDefinition​

A definition describes one logical dataset — where it comes from, how it syncs, and what makes a row unique.

Fields​

FieldTypeRequiredDescription
datasetNamestring✅Unique identifier (e.g. invoiceActivity, qrScans)
kindenum✅fact (measures over time) or dimension (lookup data)
sourceenum✅databricks, mongo, or http
sourceRefstring✅Upstream identifier — catalog path, collection name, or URL
syncModeenum✅scheduled or manual
syncSchedulestringCron expression for scheduled sync (e.g. 0 6 * * *)
grainColumnsstring[]Upstream columns that form the natural key (see Grain Keys)
enabledbooleanWhether scheduled sync runs (default true)
baseFilteranyDefault filter applied to every query
indexedDimsstring[]Dimension fields with dedicated compound indexes
schemaVersionintIncremented when field layout changes (default 1)
syncStatusenumok, drift, error, or pending (default pending)
lastSyncedAtisoDateTimestamp of last successful sync
lastSyncErrorstringError message from the last failed sync
rowCountintNumber of rows in the local store (default 0)
descriptionstringHuman-readable description

Grain Keys​

The grainColumns field is the most important configuration on a dataset definition. It declares which upstream columns form the natural key — the combination of values that uniquely identifies a row.

{
"grainColumns": ["invoice_id", "line_num", "shipment_skey"]
}

The sync engine hashes these values to produce a grainKey on each DatasetRow. This key drives upsert matching: on subsequent syncs, rows with the same grain key are updated rather than duplicated.

Getting the grain wrong is dangerous. If the grain is too coarse (missing a key column), distinct upstream rows collapse into one — silently discarding data. If the grain is too fine (including a volatile column), every sync inserts new rows instead of updating.


DatasetField​

Field metadata describes each column in the dataset: what role it plays, how it aggregates, and whether a human has reviewed the classification.

Fields​

FieldTypeRequiredDescription
datasetIdobjectId✅Reference to DatasetDefinition
columnNamestring✅Column name as it appears upstream
labelstring✅Human-readable display label
roleenum✅dimension, measure, or excluded
dataTypeenum✅string, int, float, boolean, or date
aggenumAggregation function: sum, avg, min, max, count, none (default none)
grainenumFor date dimensions: day, week, month, quarter, year
provenanceenum✅generated (overwritable on sync) or curated (preserved on sync)
needsReviewbooleanFlagged for human review (default false)
lastSeenAtisoDateLast time this column appeared in the upstream schema
positionintDisplay order (default 0)

Roles​

  • dimension — A groupable column. Used in rollup({ by: [...] }) and topN({ by: '...' }). Examples: marketCategory, customerId, region.
  • measure — A numeric value that gets aggregated. Each measure declares its agg function so callers cannot accidentally SUM an average. Examples: netSales (sum), avgDaysToShip (avg), orderCount (count).
  • excluded — Ignored by the query engine. Use for columns that exist upstream but aren't useful for analytics (raw IDs, internal flags).

Provenance​

Provenance controls what happens to a field during re-sync:

  • generated — Auto-classified by the sync engine. Will be overwritten if the upstream schema changes.
  • curated — Manually reviewed and set by a human. Preserved across syncs even if the column disappears temporarily.

DatasetRow​

The row store holds the actual data. All datasets share one collection, discriminated by datasetId.

Fields​

FieldTypeRequiredDescription
datasetIdobjectId✅Which dataset this row belongs to
grainKeystring✅Natural key for upsert (hash of grain column values)
bucketDateisoDate✅Time dimension for time-series queries
dimensionsobject✅Groupable values: { marketCategory: 'Frozen', region: 'West' }
measuresobject✅Numeric values: { netSales: 1234.56, qty: 42 }
syncedAtisoDate✅When this row was last synced

Indexes​

IndexPurpose
{ datasetId, grainKey } (unique)Upsert matching — the natural key
{ datasetId, bucketDate }Time-range queries in trend()
{ datasetId, 'dimensions.$**' } (wildcard)Fast drilldown on any dimension value

The wildcard index on dimensions.$** means queries that filter on dimension values (e.g. where: { marketCategory: 'Frozen' }) hit an index rather than scanning the collection.


DatasetService (Read Path)​

The DatasetService compiles queries against DatasetField metadata into push-down MongoDB aggregation pipelines. The service validates dimension and measure names against the field catalog, so typos and invalid columns fail fast with clear error messages.

All three methods require the datasetrow:view:all permission.

rollup()​

Aggregate rows by one or more dimensions.

interface RollupQuery {
dataset: string; // Dataset name (matches DatasetDefinition.datasetName)
by: string[]; // Dimension fields to group by
where?: Record<string, any>; // Filters on dimensions and time range
measures?: string[]; // Measure fields to aggregate (defaults to all)
}

Each measure is aggregated according to its declared agg function from the DatasetField metadata.

const result = await svc.rollup({
dataset: 'invoiceActivity',
by: ['marketCategory'],
measures: ['netSales', 'cogs'],
where: {
bucketDate: { $gte: '2026-06-01', $lte: '2026-08-31' },
region: 'West',
},
});

// result.rows → [
// { marketCategory: 'Frozen', netSales: 45231.89, cogs: 32100.00 },
// { marketCategory: 'Dairy', netSales: 28790.12, cogs: 19500.00 },
// ]

trend()​

Time-series aggregation — bucket measures by a time grain.

interface TrendQuery {
dataset: string;
grain: 'day' | 'week' | 'month' | 'quarter' | 'year';
measures?: string[];
where?: Record<string, any>;
}
const result = await svc.trend({
dataset: 'invoiceActivity',
grain: 'month',
measures: ['netSales'],
where: { marketCategory: 'Frozen' },
});

// result.rows → [
// { bucket: '2026-06', netSales: 12000.00 },
// { bucket: '2026-07', netSales: 15400.00 },
// { bucket: '2026-08', netSales: 17831.89 },
// ]

topN()​

Rank a single dimension by a single measure.

interface TopNQuery {
dataset: string;
by: string; // Dimension to rank
measure: string; // Measure to sort by
n?: number; // Results to return (default: 10, max: 1000)
sort?: 'asc' | 'desc'; // Sort direction (default: 'desc')
where?: Record<string, any>;
}
const result = await svc.topN({
dataset: 'invoiceActivity',
by: 'customerId',
measure: 'netSales',
n: 5,
});

// result.rows → [
// { customerId: 'C-4021', netSales: 98234.56 },
// { customerId: 'C-1187', netSales: 72100.00 },
// ...
// ]

Filtering​

The where clause supports dimension filters and time ranges:

where: {
// Time range — applied directly to bucketDate
bucketDate: { $gte: '2026-01-01', $lte: '2026-06-30' },

// Dimension filters — matched against dimensions.{key}
region: 'West',
marketCategory: { $in: ['Frozen', 'Dairy'] },
}

DatasetSyncService (Write Path)​

The sync service is a standalone function (not a class method) that runs with system auth. It is source-agnostic: you provide a fetchSource callback that returns rows from whatever upstream system you use.

syncDataset()​

import { syncDataset } from '@generated/datalayer/services/dataset-sync.service';

const result = await syncDataset(
'invoiceActivity', // dataset name
fetchSource, // callback: (ctx) => Promise<SourceRow[]>
{ grainColumns: ['invoice_id', 'line_num', 'shipment_skey'] },
{ batchSize: 500, log: console.log }
);

Parameters​

ParameterTypeDescription
datasetNamestringName matching a DatasetDefinition
fetchSourcefunctionCallback that receives { definition, since?, maxRows? } and returns rows
configobject{ grainColumns: string[], bucketColumn?: string }
optsSyncOptionsOptional: since, maxRows, batchSize, mode, dryRun, log

SyncOptions​

OptionTypeDefaultDescription
sincestringISO date — only sync rows newer than this
maxRowsnumberHard cap on source rows to fetch
batchSizenumber1000Rows per bulkUpsert batch
modestring'upsert''upsert' updates existing, 'replace' wipes and re-inserts
dryRunbooleanfalseRun the full pipeline but write nothing
logfunctionno-opLogger for progress messages

SyncResult​

interface SyncResult {
dataset: string;
sourceRows: number;
inserted: number;
updated: number;
skipped: number;
errors: string[];
durationMs: number;
dryRun: boolean;
}

Sync Pipeline​

The sync engine runs a seven-step pipeline:

  1. Resolve definition — Look up the DatasetDefinition by name
  2. Set status — Mark syncStatus: 'pending'
  3. Load field plan — Read DatasetField rows, classify into dimensions/measures
  4. Fetch source — Call your fetchSource callback
  5. Map rows — Transform source rows into DatasetRow shape using the field plan
  6. Write rows — Batch upsert into the DatasetRow collection
  7. Status writeback — Update syncStatus, lastSyncedAt, rowCount

If any step throws, the engine writes syncStatus: 'error' and lastSyncError back to the definition before re-throwing.

Field Plan​

The sync engine builds a field plan from DatasetField rows:

interface FieldPlan {
dimensions: { column: string; name: string }[];
measures: { column: string; name: string; agg: string }[];
bucketColumn: string | null;
grainColumns: string[];
}
  • Fields with role: 'excluded' are skipped
  • Date-typed dimension fields auto-detect as the bucketColumn
  • Non-finite measure values (NaN, Infinity) are dropped to prevent poisoning $sum rollups
  • Fields with needsReview: true are logged as warnings but still included

Writing a fetchSource Callback​

The callback receives context about the dataset and returns flat rows:

const fetchSource: FetchSource = async (ctx) => {
// ctx.definition — the full DatasetDefinition document
// ctx.since — ISO date string (if incremental sync)
// ctx.maxRows — row cap (if set)

const { sourceRef } = ctx.definition;

// Databricks example
const rows = await databricksClient.query(
`SELECT * FROM ${sourceRef}` +
(ctx.since ? ` WHERE updated_at >= '${ctx.since}'` : '') +
(ctx.maxRows ? ` LIMIT ${ctx.maxRows}` : '')
);

return rows;
};

The returned rows should be flat objects keyed by column name:

[
{ invoice_id: 'INV-001', line_num: 1, marketCategory: 'Frozen', netSales: 1234.56, invoiceDate: '2026-08-15' },
{ invoice_id: 'INV-001', line_num: 2, marketCategory: 'Dairy', netSales: 890.00, invoiceDate: '2026-08-15' },
]

Dry Run​

Use dryRun: true to validate the full pipeline without writing:

const result = await syncDataset('invoiceActivity', fetchSource, config, {
dryRun: true,
log: console.log,
});

console.log(`Would sync ${result.inserted} rows (${result.skipped} skipped, ${result.errors.length} errors)`);

API Routes​

When datasets is enabled, Radish generates standard CRUD routes for all three entities plus analytics endpoints on DatasetService:

MethodPathDescription
GET/api/v1/datasetdefinitionsList dataset definitions
POST/api/v1/datasetdefinitionsCreate a definition
GET/api/v1/datasetdefinitions/:idGet a definition
PATCH/api/v1/datasetdefinitions/:idUpdate a definition
DELETE/api/v1/datasetdefinitions/:idDelete a definition
GET/api/v1/datasetfieldsList dataset fields
POST/api/v1/datasetfieldsCreate a field
GET/api/v1/datasetrowsList raw rows
POST/api/v1/datasetrows/rollupRollup aggregation
POST/api/v1/datasetrows/trendTrend aggregation
POST/api/v1/datasetrows/topnTop-N ranking

Rollup endpoint​

curl -X POST http://localhost:5173/api/v1/datasetrows/rollup \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dataset": "invoiceActivity",
"by": ["marketCategory"],
"measures": ["netSales"],
"where": { "bucketDate": { "$gte": "2026-06-01" } }
}'

Integration with Scheduled Jobs​

Datasets pair naturally with Scheduled Jobs for automated sync. The scheduler and handler registry are app code you write (see Scheduled Jobs: What You Build). Here is the recommended pattern for a dataset sync handler:

// YOUR APP CODE — register a handler in your job registry
import { registerJobHandler } from './jobs/registry';
import { syncDataset } from '@generated/datalayer/services/dataset-sync.service';

registerJobHandler('datasets:sync-invoices', async (context) => {
const result = await syncDataset('invoiceActivity', fetchFromDatabricks, {
grainColumns: ['invoice_id', 'line_num', 'shipment_skey'],
}, {
since: context.lastRunAt,
log: (msg) => context.log(msg),
});

// Optionally record sync metrics as a job snapshot
if (context.snapshot) {
context.snapshot({}, {
rows_synced: result.inserted + result.updated,
errors: result.errors.length,
duration_ms: result.durationMs,
});
}
}, {
name: 'Invoice Activity Sync',
});

Then create the corresponding ScheduledJob record (via API, admin panel, or seed script) with handler: 'datasets:sync-invoices' and schedules: ['0 6 * * *'].


Best Practices​

DO:​

  • ✅ Set grainColumns on every dataset — it prevents silent duplicates
  • ✅ Mark field provenance as curated after manual review
  • ✅ Use dryRun: true on the first sync of a new dataset
  • ✅ Set agg on every measure field — prevents accidental SUM of averages
  • ✅ Filter by bucketDate in queries to limit the aggregation window
  • ✅ Use incremental sync (since option) for large datasets

DON'T:​

  • ❌ Use datasets for data already in your app's entities — query those directly
  • ❌ Skip DatasetField setup — the sync engine refuses to run without fields
  • ❌ Use replace mode in production without understanding the data gap — rows are deleted before new ones are inserted
  • ❌ Set grain columns to include volatile fields (timestamps, sequence numbers) — this causes unbounded row growth

Next Steps​

  • Job Snapshots — Record sync metrics alongside your scheduled jobs
  • Services — How the generated service layer works
  • Routes — API route generation details
  • Access Control — Permission model for dataset queries