src/nosql/influxdb/flux.ts · src/nosql/influxdb/index.ts · Tests: tests/nosql/influxdb-flux.test.ts, tests/nosql/influxdb-flux-store.test.tsInfluxDBStore has always had a structured write surface — writePoint, writeMeasurement, create/bulkCreate, buffered line protocol with an explicit flush(). Reading was query(flux) / queryRaw(flux): the Flux script was a string you wrote by hand, which is exactly what this library's no-raw-query principle forbids. This page documents the structured read path that closes that gap.query(), queryRows(), collectRows() and queryRaw() have not gone anywhere. They are the escape hatch for what Flux can express and this compiler deliberately will not, and every refusal below names the feature and points back at them.| Method | Does |
|---|---|
store.buildFlux(spec) | Compiles a FluxQuerySpec to a Flux script. Pure — nothing is sent. |
store.runFlux(spec) | Compiles, runs, collects every row into an array. |
store.streamFlux(spec) | Compiles, runs, yields rows lazily for for await. |
store.rawFlux(spec) | Compiles, runs, returns the annotated-CSV body. |
compileFlux(spec, { bucket }) | The compiler on its own, with no store attached. |
import { InfluxDBStore } from 'ts-prorm-orm/nosql/influxdb';
const store = new InfluxDBStore({ url: 'http://localhost:8086', token: process.env.INFLUX_TOKEN!, org: 'acme', bucket: 'telemetry' });
await store.connect();
const rows = await store.runFlux({
range: { start: '-24h', stop: { now: true } },
pipeline: [
{ op: 'filter', measurement: 'cpu', field: 'usage_user', tags: { host: 'web-1' } },
{ op: 'aggregateWindow', every: '5m', fn: 'mean', createEmpty: false },
{ op: 'sort', columns: ['_time'], desc: true },
{ op: 'limit', n: 100 },
],
yield: 'cpu_5m',
});from(bucket: "telemetry")
|> range(start: -24h, stop: now())
|> filter(fn: (r) => r._measurement == "cpu" and r._field == "usage_user" and r.host == "web-1")
|> aggregateWindow(every: 5m, fn: mean, createEmpty: false)
|> sort(columns: ["_time"], desc: true)
|> limit(n: 100)
|> yield(name: "cpu_5m")import { Op } from 'ts-prorm-orm';
import { InfluxDBStore } from 'ts-prorm-orm/nosql/influxdb';
const store = new InfluxDBStore({ url: 'http://localhost:8086', token: process.env.INFLUX_TOKEN!, org: 'acme', bucket: 'telemetry' });
await store.connect();
try {
// write two points; buffered until flush()
store.writeMeasurement({ measurement: 'http', tags: { route: '/api/orders' }, fields: { duration_ms: 42, status: 200 }, intFields: ['status'] });
store.writeMeasurement({ measurement: 'http', tags: { route: '/health' }, fields: { duration_ms: 1, status: 200 }, intFields: ['status'] });
await store.flush();
// 1. p95 latency per route in 15-minute windows
const p95 = await store.runFlux({
range: { start: '-6h' },
pipeline: [
{ op: 'filter', measurement: 'http', field: 'duration_ms', where: { status: { [Op.gte]: 200 }, route: { [Op.ne]: '/health' } } },
{ op: 'group', columns: ['route'] },
{ op: 'aggregateWindow', every: '15m', fn: 'quantile', q: 0.95, createEmpty: false },
{ op: 'yield', name: 'p95' },
],
});
// 2. fields to columns, then a computed column
const busy = await store.runFlux({
range: { start: '-1h' },
pipeline: [
{ op: 'filter', measurement: 'cpu', tags: { host: 'web-1' } },
{ op: 'pivot', rowKey: ['_time'], columnKey: ['_field'], valueColumn: '_value' },
{ op: 'map', columns: { busy: { arith: '-', left: { value: 100 }, right: { col: 'usage_idle' } } } },
{ op: 'keep', columns: ['_time', 'host', 'busy'] },
],
});
// 3. per-second rate of a counter, top five
const top = await store.runFlux({
range: { start: '-1h' },
pipeline: [
{ op: 'filter', measurement: 'net', field: 'bytes_recv' },
{ op: 'derivative', unit: '1s', nonNegative: true },
{ op: 'top', n: 5, columns: ['_value'] },
],
});
console.log(p95.length, busy.length, top.length);
} finally {
await store.disconnect(); // flushes and closes the write APIs
}// 1.
from(bucket: "telemetry")
|> range(start: -6h)
|> filter(fn: (r) => r._measurement == "http" and r._field == "duration_ms" and (r.status >= 200.0 and r.route != "/health"))
|> group(columns: ["route"])
|> aggregateWindow(every: 15m, fn: (column, tables=<-) => tables |> quantile(q: 0.95, column: column), createEmpty: false)
|> yield(name: "p95")
// 2.
from(bucket: "telemetry")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "cpu" and r.host == "web-1")
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|> map(fn: (r) => ({r with busy: (100.0 - r.usage_idle)}))
|> keep(columns: ["_time", "host", "busy"])
// 3.
from(bucket: "telemetry")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "net" and r._field == "bytes_recv")
|> derivative(unit: 1s, nonNegative: true)
|> top(n: 5, columns: ["_value"])r.status >= 200.0 in the first pipeline: numbers compile as floats unless you mark them, which is the subject of a section below.findAllgroup |> aggregateWindow aggregates per group; aggregateWindow |> group aggregates first and groups the results. Different numbers.pivot before a filter changes which columns that filter can even see, because pivot turns fields into columns.limit before sort takes an arbitrary hundred rows and sorts them; sort before limit takes the top hundred.{ where, order, limit } object has exactly one implied order, fixed by whoever wrote the compiler, so it cannot express any of that. The spec is therefore a source plus an ordered array of stages — which is what the language is:interface FluxQuerySpec {
bucket?: string; // from(bucket:) — falls back to the store's default
bucketID?: string; // from(bucketID:)
union?: FluxQuerySpec[]; // union(tables: [...])
join?: FluxJoinSpec; // join(tables: {left, right}, on:)
range?: FluxRange; // range(start:, stop:) — required for a bucket source
pipeline?: FluxStage[]; // the stages, in order
yield?: string; // shorthand for a trailing yield(name:)
}filter takes the ORM's own where, sort takes columns and a direction, limit takes n and offset — so the shape is honest about Flux without being alien.union and join are heads, not stages, because union() and join() do not receive a piped table; they take whole streams as arguments and so cannot follow a |>. Their sub-queries are hoisted to _t0, _t1, … assignments:compileFlux({
union: [
{ bucket: 'a', range: { start: '-1h' }, pipeline: [{ op: 'filter', measurement: 'cpu' }] },
{ bucket: 'a', range: { start: '-1h' }, pipeline: [{ op: 'filter', measurement: 'mem' }] },
],
pipeline: [{ op: 'sort', desc: true }],
});_t0 = from(bucket: "a")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "cpu")
_t1 = from(bucket: "a")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "mem")
union(tables: [_t0, _t1])
|> sort(columns: ["_time"], desc: true)op | Compiles to |
|---|---|
range | range(start:, stop:) — narrow the window again mid-pipeline |
filter | filter(fn: (r) => …, onEmpty:) |
aggregateWindow | aggregateWindow(every:, period:, offset:, fn:, column:, timeSrc:, timeDst:, createEmpty:) |
aggregate | a bare mean() / sum() / count() / quantile(q:) / … |
first, last, max, min | the row selectors, with an optional column: |
top, bottom | top(n:, columns:) / bottom(n:, columns:) |
group, ungroup | group(columns:, mode:) / group() |
pivot | pivot(rowKey:, columnKey:, valueColumn:) |
sort | sort(columns:, desc:) |
limit, tail | limit(n:, offset:) / tail(n:, offset:) |
map | map(fn: (r) => ({r with …})) |
fill | fill(column:, value:) or fill(usePrevious: true) |
derivative | derivative(unit:, nonNegative:, columns:, timeColumn:, initialZero:) |
difference | difference(nonNegative:, columns:, keepFirst:, initialZero:) |
movingAverage, exponentialMovingAverage, timedMovingAverage | the three moving averages |
window | window(every:, period:, offset:, createEmpty:) |
cumulativeSum, increase | running totals |
elapsed | elapsed(unit:, timeColumn:, columnName:) |
distinct, unique | distinct(column:) / unique(column:) |
keep, drop | keep(columns:) / drop(columns:) |
rename, duplicate, set | column surgery |
timeShift | timeShift(duration:, columns:) |
toFloat, toInt, toUInt, toString, toBool | _value type conversions |
yield | yield(name:) |
aggregateWindow(fn:) are mean, median, sum, count, min, max, first, last, mode, spread, stddev, skew, quantile and integral. The two that take their own arguments cannot be named bare, so they are emitted in the lambda form aggregateWindow also accepts, with the window's column: threaded through:|> aggregateWindow(every: 1h, fn: (column, tables=<-) => tables |> quantile(q: 0.99, column: column))filter uses the ORM's own operatorswhere is fed through normalizeWhere() from src/graph/types.ts — the single place the Op.* symbol and $string operators are folded into one tree, shared with the Couchbase N1QL and CQL compilers. Re-parsing WhereOptions here would be a second copy that drifts.| Operator | Flux |
|---|---|
eq ne gt gte lt lte | r.x == v, !=, >, >=, <, <= |
in / notIn | contains(value: r.x, set: [...]), negated |
between / notBetween | (r.x >= a and r.x <= b), negated |
isNull / isNotNull | not exists r.x / exists r.x — Flux has no NULL |
like notLike iLike notILike | an anchored regex, %→.* and _→. |
startsWith endsWith substring (+ negations) | strings.hasPrefix / hasSuffix / containsStr |
regexp / notRegexp | r.x =~ /…/ / !~ |
startsWith and friends deliberately do not compile to a regex: the caller supplied a literal, not a pattern, so a % or a . in it must stay itself. The strings package functions treat it as text, and the import "strings" line is added to the script automatically.{ op: 'filter', measurement: 'cpu', field: ['usage_user', 'usage_system'], tags: { host: 'web-1' } }contains(...); a scalar becomes ==. A filter with none of measurement / field / tags / where is refused rather than emitted, since an empty predicate silently matches everything.floatField, so a bare JavaScript number compiles to a float literal — 5 becomes 5.0. When the column really is an integer, say so:{ op: 'filter', where: { retries: { [Op.gte]: { int: 3 } } } }{ float }, { int }, { uint }, { duration }, { time } and { regex } are all accepted anywhere a value is.@influxdata/influxdb-client@1.35 builds its query request as {query, dialect, type} — there is no params field, so InfluxDB's server-side params mechanism is unreachable here. The driver's fluxString / fluxDuration / flux `` helpers are client-side escapers, not bindings: they concatenate into the script exactly as this compiler does. So there is nothing to bind to, and correctness of escaping is the only thing between a caller's value and an altered script. src/graph/sparql/terms.ts is in the same position for SPARQL, and this module follows its discipline: one audited place where a JavaScript value becomes query text.\, ", LF, CR and TAB are escaped. Two details that are easy to get wrong:${ is escaped to \${, because a Flux string literal interpolates. "${r.token}" is an expression, not six characters. A value containing ${…} would otherwise be evaluated inside the script. The driver's own fluxString does not do this.| Thing | Rule |
|---|---|
Durations (every, unit, offset, timeShift) | -?(\d+(ns|us|ms|s|m|h|d|w|mo|y))+ |
Times (range.start, range.stop) | a duration, RFC3339, a Date, epoch ms, or { now: true } |
map output columns, rename source and target | [A-Za-z_][A-Za-z0-9_]* — they are record keys |
Aggregate function names, map function names | a fixed whitelist |
mode, method, onEmpty, map mode | a fixed set of literals |
n, offset, desc, createEmpty, … | non-negative integers / real booleans |
user id into user_id would produce a column the caller never asked for.r["a b"] is a genuine string literal. The dot form r.host is used when the name is an identifier, purely for readability; the bracket form is the always-correct fallback, so any column name at all is expressible./ terminates a Flux regex literal, so every unescaped one is escaped; control characters are refused; the pattern must compile; and lookaround and backreferences are refused because Flux runs Go's RE2, which does not implement them — shipping one would only fail server-side. Patterns are capped at 1024 characters. RE2 is linear-time, so catastrophic backtracking is not reachable on the server, and this compiler only ever constructs a caller's pattern, never executes it, so it cannot backtrack here either.FluxCapabilityError — a typed error naming the feature, following the GraphCapabilityError pattern — rather than a script the server would reject:| Refused | Reason |
|---|---|
A bucket source with no range | Flux requires range() immediately after from(). Emitting the from() alone would be a guaranteed server error. |
A join method other than inner | universe.join() documents the others but implements only inner; the newer join package (join.left, join.full, …) takes as: functions rather than an on: column list, a different shape this spec does not model. |
| An aggregate name outside the whitelist | It is emitted unquoted, so a closed set is the only safe design. |
A map function outside the whitelist | Same reason. |
A stage op the compiler does not emit | Named in the error so you can see what to reach for query() for. |
ValidationError covers the malformed rather than the impossible: a non-duration duration, a non-RFC3339 time, a record key that is not an identifier, a fill with both value and usePrevious (or neither), an empty filter, a union of fewer than two streams, a range on a union/join spec, q outside 0..1, a non-integer n.@influxdata/influxdb-client posts to /api/v2/query with a request body whose type its own TypeScript declares as the literal 'flux' (QueryOptions.type?: 'flux')./query) and needs DBRP mappings. This client cannot address it at all.@influxdata/influxdb3-client, over Flight).to() and other write stages. Reading and writing stay separate surfaces; the buffered write path is the one to use, and it is untouched.location: on aggregateWindow/window. It takes a timezone record from the timezone package, a shape with its own import and semantics.histogram / histogramQuantile / holtWinters / stateDuration. Real Flux functions, but each has enough of its own argument surface to deserve its own design pass rather than a hurried stage.strings.* beyond the handful in the map whitelist, and the regexp, json, experimental and sql packages.option declarations. A script that defines its own functions is a program, not a query. query() is there for that.findAll / aggregate / count methods on the store are unchanged. They remain a convenience shim with their own predicate pushdown (./flux-compiler); the pipeline API above is the one that covers Flux properly.InfluxDBStore. tests/nosql/influxdb-flux.test.ts tests the compiler as a pure function — no driver at all — and tests/nosql/influxdb-flux-store.test.ts runs the compiled scripts against the in-memory driver mock.|> drop(columns: [...]), and the result is asserted to be either exactly escaped or refused — again by exact match, because a test that only checked the payload "appears somewhere" would pass against a compiler that interpolated it.src/nosql/influxdb/README.md — the store itself: connection lifecycle, points, and the write-buffering gotchaOp.* surface being compiled