Data runtime in 0.2.0
Canonical Quirl project documentation synced from docs/data-runtime.md.
Streaming failure model and invariants
- Cancellation is checked before source initialization, before and during every pull, and before each transform or renderer write. Cancellation after a partial write remains an error; already-written bytes are not reported as a successful value.
- CLI execution also arms the plan's one monotonic absolute deadline before data initialization. Expiry sets the same engine-observed cancellation flag, covers parsing, adapters, transforms, pulls, materialization, and interactive writes, and is normalized back to the plan deadline error only after readers and external processes have cleaned up. The external-process policy may choose its earlier two-second security deadline but cannot extend the plan.
- A lazy source owns its reader until the stream is dropped. Adapter failure after earlier rows does not manufacture a successful partial result, and dropping the stream after success, cancellation, or error closes the reader.
- Values remain
DataValuefrom source ingress through transforms and shared execution outcomes. JSON, YAML, TOML, and byte strings are named conversion boundaries; none is the evaluator's internal representation. - Every conversion validates bytes, rows, record width, envelope/value nesting depth, value
nodes, retained text, and materialized bytes. A bridge fails with
ResourceLimitbefore retaining the first value beyond its configured bound, and reports the configured limit and observed use when safe. sort/sort-by,last,reverse,uniq,group-by,columns,math, table rendering,DataOutput::into_envelope, and the collected convenience APIs are the only materializing stream operations, each bounded by the row limit. Lazylines, CSV, tar,where,get,select,reject,take,first <count>, andskipretain an O(one-row) window.- Type mismatches and lossy conversions are operating errors. No adapter, transform, or renderer panics on user input, recurses over untrusted value structure, or performs a hidden JSON round trip.
firstandlastwithout a count are the focused grammar's optional operations: an empty stream becomesOption::Noneand a present row becomesOption::Some. With a count they return a list of at most that many rows. Operating failures remainShellErrorunless a caller explicitly requests a result envelope.Taskremains bounded declarative state; this runtime does not claim a scheduler or asynchronous execution.- A successful shared execution outcome has one zero status and one typed value
representation. Cancellation, adapter failure, bridge overflow, and
transform errors remain
Err(ShellError)after owned readers are released; they never become a successful status with a partial value.
quirl data evaluates native structured values and renders them explicitly:
quirl data 'open users.csv | where enabled == "true" | select name' --format table
quirl data 'open config.toml | get service.port' --format plain
quirl data 'open users.json' --format json
quirl data '^external printf "{\"ok\":true}" | from json'Interactive Data mode renders records as tables. Record streams, including
inline JSON arrays, render in batches of at most 256 rows; a batch older than
100 ms renders with the next pulled record, so slow sources show rows early
while memory stays bounded. Rows pulled before a failure remain visible.
Scalar rows such as lines output stay line-oriented. quirl data ... --format table materializes one complete bounded table.
Table output measures rendered Unicode cell widths and presents records in a
compact rounded frame with padded cells and a header divider. Numeric, size,
and duration values are right-aligned. It never relies on terminal tab stops,
so alignment is stable in the rich viewport and redirected text. A single
record renders vertically as one key │ value line per field. Nested values
inside cells are summarized as {record 2 fields}, [table 3 rows], or
[list 4 items]; use get or --format json to see them in full.
Tables fit the terminal. In the rich surface, and for quirl data when
standard output is a terminal, the widest columns shrink and end in …, never
below eight cells or their heading width (up to twelve). Trailing columns that
still do not fit collapse into one … column. Redirected output keeps every
cell at its natural width.
Filesystem tables use a focused display projection (#, name, kind,
size, and modified): headings and values are the record's own fields, so
where kind == "directory" filters exactly what the table shows. Sizes use
decimal units and Unix modification times
are shown relative to one time captured for the complete render. The underlying
records retain path, hidden, readonly, and target for transforms and
machine formats; exceptional values are added to the table only when present.
The rich surface keeps every outline glyph in one subdued theme color and
applies the accent only to heading text; redirected table output remains free
of ANSI escapes.
Transforms
The transform vocabulary follows Nushell's names where the behavior matches:
| Transform | Result |
|---|---|
where <field> <op> <value> [and|or ...] | Rows whose fields compare as requested |
select <field>... / reject <field>... | Rows with only, or without, the named fields |
get <field.path> | The field's value from a record or from each row |
sort-by <field> [-r] (or sort <field> [asc|desc]) | Rows ordered by a field |
first [n] / last [n] / take <n> / skip <n> | Rows by position |
reverse / uniq | Rows reversed, or the first of each distinct value |
group-by <field> | A record from each distinct field value to its rows |
columns | The field names of a record or of all rows |
math sum|min|max|avg | One value reduced from a list |
length | The number of items |
where compares sizes with size literals written with a unit and no quotes:
ls | where size > 10kB. Decimal units (kB, MB, GB, TB, PB) count
in thousands and binary units (KiB, MiB, GiB, TiB, PiB) in 1024s,
case-insensitively. reject and group-by report a missing field instead of
ignoring it, so a typo cannot silently keep data.
math keeps numbers exact: integers and decimals add as decimal digits, never
through binary floating point, and a total beyond 38 significant digits fails
rather than rounding. An average is exact when the division terminates and
otherwise rounds to 12 decimal places. Sizes and durations reduce only with
their own kind and keep their unit; min and max also compare text and dates.
Syntax ownership and analysis
quirl-data owns the focused data lexer, AST, inert diagnostics, formatter,
and data-token highlighting because those contracts name data sources,
byte/value bridges, transforms, predicates, and data-domain types.
quirl-syntax remains the independent command-grammar foundation. This keeps
the accepted ADR 0016 dependency graph unchanged: the CLI composition root
maps data diagnostics into ShellError for execution and check/format
boundaries, and injects a side-effect-free native analyzer callback into
quirl-lsp rather than adding an LSP-to-data dependency.
Parsing never opens a path, reads the current directory, invokes an adapter, or
calls an external process. The iterative lexer and literal parser preserve
half-open UTF-8 byte spans and reject input before retaining more than 256 KiB
of source, 32,768 tokens, 64 levels of nesting, 100,000 AST nodes, 256 fields
per record/field list, or 64 KiB per decoded literal by default. Library
callers can replace every syntax budget through DataSyntaxLimits.
The AST describes the currently implemented pwd, files/ls, open, JSON
literal, and ^external sources; lines, from json, and to json bridges;
and get, where, select, reject, sort/sort-by, take, first,
last, skip, reverse, uniq, group-by, columns, math, and length
transforms.
Its DataType surface names existing scalar/domain DataValue forms plus
List, Record, Table, Option, Result, Task, Stream, and Command.
Those type nodes remain focused semantic vocabulary rather than general type
inference. The evaluator now preserves DataValue through live streams and
implements first as Option; Task still does not claim scheduling.
An empty expression is now a syntax diagnostic instead of the earlier
implementation accident that produced null; write null explicitly. Bare
paths containing whitespace also receive a migration diagnostic directing the
author to quote the complete path.
Numeric where predicates and sort compare retained decimal text exactly,
including comparisons with signed and unsigned integers. Distinct values above
2^53 remain distinct, trailing decimal zeros do not change equality, and a tiny
nonzero decimal does not compare equal to zero through binary-float underflow.
Decimal exponents must fit a signed 64-bit integer; a larger exponent produces
ResourceLimit. Comparisons scan the bounded input digits with constant extra
storage and never materialize the zeros implied by an exponent. This applies
to the values retained by the runtime; comparison cannot recover precision
already lost at a JSON, YAML, or TOML conversion boundary.
get, where, and sort accept dotted cell paths that index into lists with
a non-negative integer segment as well as into records by field name, for
example get items.0.name. A path accepted by get or where may end with a
trailing ? to make the whole path optional: get b? returns Nothing
instead of failing when a field or index is absent, and where b? == 1
treats an absent b as a non-match instead of erroring. Without the ?
marker, get and where fail closed on a field a row does not have, so a
typo in a path is a diagnostic rather than a silently empty result.
select requires every requested field in each consumed record. A missing
field reports the transform's source range and a bounded list of available
fields instead of silently omitting the column. Deferred stream failures keep
that original location even when a later transform pulls the row. Empty inputs
remain empty; projections preserve the requested field order.
Record is an insertion-ordered mapping, not an alphabetized one: select
emits fields in the order requested rather than sorted order, and table
rendering unions columns across heterogeneous rows in first-seen order. A
record's own field order otherwise follows however it was constructed (for
example, the order YAML or CSV headers declared it); native JSON literals and
from json still normalize to serde_json's parse order, which is
alphabetical, since that conversion boundary does not yet preserve source
order end to end.
--format json emits a tagged Value or Stream envelope so a script
does not need to infer whether an array is an ordinary value or a pipeline
stream. Values themselves retain an ABI tag (int, decimal, path, size,
and so on) rather than making domain values look like strings. The current
native parser emits generic scalar/list/record values. Filesystem rows preserve
Path, Size, and optional DateTime fields; tar rows preserve Path and
Size; TOML datetimes remain DateTime. Option, Result (ok or error),
and Task (pending, complete, cancelled, or failed) remain explicit in
the same ABI. first produces Option::Some or Option::None.
DataRuntime::eval_result_envelope is the intentional boundary that captures
an operating error; other evaluator entry points return ShellError. Task
states are validated declarative data and never start asynchronous work.
Supported adapters are JSON, YAML, TOML, CSV, uncompressed POSIX tar archive
inspection, and filesystem rows (files [path], with ls retained as an
alias). CSV and tar entries are pull-based: a row is parsed when the consumer
asks for it, and cancellation is checked before each pull. The public CLI
writes plain and JSON rows directly to stdout as it pulls them, keeping those
paths O(window) rather than constructing a complete output string. It keeps
that laziness through where, get, select, and take; sort, table
rendering, DataOutput::into_envelope, and the collected eval/render
convenience APIs are deliberate bounded collection boundaries. render_to is
the streaming output boundary. eval_typed is the collected typed API;
the older eval method is retained as a named JSON-compatibility bridge for
script and watch consumers and therefore applies the same documented loss as
to json.
JSON, YAML, TOML, and directory entries are validated then materialized because
their current underlying parsers expose whole-document APIs.
YAML anchors and aliases are rejected by a syntax-aware preflight before the
whole-document parser can expand them. Quotes and block-style punctuation inside
plain scalars do not hide later references; genuine quoted and block scalar
text remains supported. Leading UTF-8 BOMs are consumed as text
encoding markers, while malformed UTF-8 is a data encoding diagnostic with
conversion guidance. CSV enforces record width while constructing both the
header and each lazily pulled row, before retaining the first excess field.
Every adapter enforces the default 8 MiB file size, 100,000 row/node, 256
field, 64 nesting-depth, 8 MiB retained-text, 16 MiB materialization, and
256 KiB expression limits. Library callers can set DataLimits explicitly;
evaluator entry points derive matching syntax byte, depth, and field limits
before parsing. Resource-limit diagnostics report the configured limit and
observed use when safe. CSV requires a single unique header row
and does not support multiline quoted fields. CSV columns remain strings,
including boolean-looking and numeric-looking cells; compare text explicitly,
for example where enabled == "true". The adapter does not infer or coerce
column types. Tar inspection lists headers
only: it never extracts entries and intentionally supports only uncompressed POSIX .tar
archives (not zip, gzip, bzip2, xz, or PAX/GNU extended-name semantics); each
header checksum is verified before the entry is reported.
Byte/value crossings are explicit. lines turns one string byte value into a
lazy stream of newline-delimited strings; from json parses a string byte
value (or each string stream item); and to json serializes a value or each
stream item. JSON never creates domain tags. At to json, paths, datetimes,
and patterns become strings; sizes and durations become strings suffixed with
B and ns; non-finite decimal text is rejected rather than silently changed.
YAML requires string mapping keys and rejects application-specific tags. TOML
converts directly without passing through JSON. ^external <command> is the
only external byte producer. A
standalone DataRuntime has no ambient process capability and rejects it. The
CLI injects the sandboxed process host only when the plan declares process
authority, with a two-second engine-local deadline and a 1 MiB combined
retained-output limit. The local deadline is intersected with the plan through
the same shareable cancellation token. Non-zero exits remain ShellError failures with
the bounded stderr context, rather than being silently converted into values.
This release intentionally does not implement SQLite, zip/compressed archive
inspection, HTTP, background task scheduling, or a
fully streaming JSON/YAML/TOML parser. HTTP is not implied by open: a future
HTTP adapter must expose explicit request, timeout, redirect, byte, and
capability limits. Those remain design targets rather than silently available
0.2.0 behavior.
The rich interactive data branch also uses a shared ExecutionRequest with an
inherited output target. It writes terminal-safe plain values one row at a time,
flushes each row, checks the shared SIGINT/SIGTERM cancellation identity before
every pull and write, observes the same absolute plan deadline, and yields after
at most 16 pulls. A failed, cancelled, or expired
stream may already have produced scrollback but never commits its partial rows
to the picker cache. Only successful typed rows enter the 128-item, 512 KiB
session cache used by Alt-D; opening that picker performs no evaluation.
Nested Option wrappers are flattened iteratively and rejected before output
at a depth above 64, so renderer stack use is independent of source nesting.