diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs
new file mode 100644
index 00000000..afe02efa
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs
@@ -0,0 +1,122 @@
+using System.Globalization;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
+
+///
+/// Push-down QuestDB SQL for processed (aggregate) reads (spec §3 "Processed", T0 Q8-confirmed forms), one query
+/// per , mirroring the SQL Server backend's SqlAggregateQueries family split and
+/// column order. Every query takes five positional parameters:
+///
+/// $1 — the (rounded) window start in 100 ns ticks since the Unix epoch
+/// (, Bigint);
+/// $2 — the bucket width in ticks (, Bigint);
+/// $3 — the tag (Varchar);
+/// $4/$5 — the forward window [start, end) (TimestampTz via
+/// ).
+///
+/// Each returns one row per non-empty bucket; the caller fills the empty ones.
+///
+///
+///
+/// Bucketing.b = (cast(ts AS LONG) * 10 − $1) / $2. cast(ts AS LONG) is µs since 1970, so
+/// × 10 is ticks since 1970; both it and $1 are µs-aligned (R6), so the integer division is
+/// start-inclusive / end-exclusive to the tick, exactly like SQL Server's. Every row is at or after the start,
+/// so the dividend is never negative (no truncation-toward-zero surprise), and nothing overflows before 9999.
+/// SAMPLE BY is deliberately not used: its alignment rules differ from ours.
+///
+///
+/// Sample classes (S6). The status is stored as the unsigned 32-bit code in a LONG, so
+/// "not Bad" is status < 0x80000000 (not T-SQL's sign test). usable = not Bad AND
+/// kind <> 0 (an Empty sample is an outage marker and counts as Bad whatever its code);
+/// good = usable AND status < 0x40000000; bad = not usable; non-numeric = usable
+/// String/DateTime. The literals are generated from and .
+///
+/// Common result columns (ordinals 0–5): b, total_count, usable_count, good_count, bad_count,
+/// non_numeric_count. Family columns follow from .
+///
+internal static class QuestDbAggregateQueries
+{
+ /// Ordinal of the first family-specific column.
+ public const int FirstFamilyColumn = 6;
+
+ /// status < SeverityBadBit, as a decimal LONG literal.
+ internal static readonly string NotBadLimit = StatusBits.SeverityBadBit.ToString(CultureInfo.InvariantCulture);
+
+ /// status < SeverityUncertainBit, as a decimal LONG literal.
+ internal static readonly string GoodLimit = StatusBits.SeverityUncertainBit.ToString(CultureInfo.InvariantCulture);
+
+ /// The kind values of String and DateTime: usable but not numeric.
+ internal static readonly string NonNumericKinds =
+ ((byte)HistorianValueKind.String).ToString(CultureInfo.InvariantCulture) + ", " +
+ ((byte)HistorianValueKind.DateTime).ToString(CultureInfo.InvariantCulture);
+
+ private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture);
+
+ ///
+ /// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad.
+ /// Family columns: 6 avg_value, 7 min_value, 8 max_value, 9 sum_value (DOUBLE, NULL when no usable sample).
+ ///
+ /// Prefixed sample table name.
+ /// The SQL text.
+ public static string Simple(string sample) =>
+ "SELECT b, " + CommonCounts() + ", " +
+ "avg(CASE WHEN u = 1 THEN num_value END) AS avg_value, " +
+ "min(CASE WHEN u = 1 THEN num_value END) AS min_value, " +
+ "max(CASE WHEN u = 1 THEN num_value END) AS max_value, " +
+ "sum(CASE WHEN u = 1 THEN num_value END) AS sum_value " +
+ "FROM (" + Classified(sample) + ") GROUP BY b ORDER BY b";
+
+ ///
+ /// Start / End / Delta: the first and last usable sample of each bucket by ts.
+ /// Family columns: 6..10 first sample (kind, num_value, int_value, str_value, ts), 11..15 last
+ /// sample (same order). A bucket without a usable sample has NULL ts but kind = 0 (BYTE has no NULL),
+ /// so the reader keys absence on the NULL time.
+ ///
+ /// Prefixed sample table name.
+ /// The SQL text.
+ public static string StartEnd(string sample) =>
+ "WITH s AS (" + Classified(sample) + "), " +
+ "st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b), " +
+ "f AS (SELECT b, kind, num_value, int_value, str_value, ts, " +
+ "row_number() OVER (PARTITION BY b ORDER BY ts ASC) AS rf, " +
+ "row_number() OVER (PARTITION BY b ORDER BY ts DESC) AS rl FROM s WHERE u = 1) " +
+ "SELECT st.*, " +
+ "x.kind, x.num_value, x.int_value, x.str_value, x.ts, " +
+ "y.kind, y.num_value, y.int_value, y.str_value, y.ts " +
+ "FROM st LEFT JOIN (SELECT * FROM f WHERE rf = 1) x ON x.b = st.b " +
+ "LEFT JOIN (SELECT * FROM f WHERE rl = 1) y ON y.b = st.b ORDER BY st.b";
+
+ ///
+ /// MinimumActualTime / MaximumActualTime: the usable sample with the smallest (largest) num_value, ties
+ /// broken by the earliest ts. Family columns: 6 num_value, 7 ts (NULL when the bucket has no usable
+ /// numeric sample).
+ ///
+ /// Prefixed sample table name.
+ /// True for MaximumActualTime.
+ /// The SQL text.
+ public static string ActualTime(string sample, bool maximum) =>
+ "WITH s AS (" + Classified(sample) + "), " +
+ "st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b), " +
+ "r AS (SELECT b, num_value, ts, " +
+ "row_number() OVER (PARTITION BY b ORDER BY num_value " + (maximum ? "DESC" : "ASC") + ", ts ASC) AS rn " +
+ "FROM s WHERE u = 1 AND num_value IS NOT NULL) " +
+ "SELECT st.*, r.num_value, r.ts " +
+ "FROM st LEFT JOIN (SELECT * FROM r WHERE rn = 1) r ON r.b = st.b ORDER BY st.b";
+
+ /// The bucket-index expression (exposed for tests).
+ internal const string BucketExpression = "(cast(ts AS LONG) * 10 - $1) / $2";
+
+ /// The classified subquery s: the window's live samples with bucket index and the usable/good flags.
+ private static string Classified(string sample) =>
+ "SELECT " + BucketExpression + " AS b, ts, kind, num_value, int_value, str_value, " +
+ $"CASE WHEN status < {NotBadLimit} AND kind <> {EmptyKind} THEN 1 ELSE 0 END AS u, " +
+ $"CASE WHEN status < {GoodLimit} AND kind <> {EmptyKind} THEN 1 ELSE 0 END AS g " +
+ $"FROM {sample} WHERE tag = $3 AND ts >= $4 AND ts < $5 AND NOT deleted";
+
+ /// The five common count columns (after b).
+ private static string CommonCounts() =>
+ "count() AS total_count, sum(u) AS usable_count, sum(g) AS good_count, count() - sum(u) AS bad_count, " +
+ $"sum(CASE WHEN u = 1 AND kind IN ({NonNumericKinds}) THEN 1 ELSE 0 END) AS non_numeric_count";
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs
new file mode 100644
index 00000000..f4b97539
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs
@@ -0,0 +1,468 @@
+using System.Globalization;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Npgsql;
+using NpgsqlTypes;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
+
+///
+/// QuestDB read side of the historian (spec §3 "Reads"): Raw (forward / reverse / point), AtTime (chunked
+/// LIMIT 1 neighbour seeks, T0 Q9), Events, and Processed aggregates pushed down to QuestDB SQL
+/// (, T0 Q8). Results match the SQL Server data source except that every
+/// timestamp is at microsecond resolution.
+///
+///
+///
+/// Timestamps (R6). Every inbound time is normalised with and every
+/// is bound only through . Read bounds are not
+/// clamped to the epoch (a pre-1970 bound is valid); only MinValue/MaxValue are nudged. Results
+/// carry the rounded times, so an AtTime result is stamped at the rounded request time.
+///
+///
+/// Reads never create tags (R7). A tag resolves only through ;
+/// an unknown tag is a successful empty read (Raw), an all-BadNoData read (AtTime / Processed), never an
+/// error. Every sample query filters NOT deleted, so a DeleteRaw/DeleteAtTime tombstone is invisible.
+///
+///
+/// Values. Every row goes through (an Empty row with a non-Bad status
+/// is served BadNoData). The value kind, not which column is populated, decides the value. QuestDB stores
+/// in a LONG as NULL (its NULL sentinel), so an Int64 row whose
+/// int_value is NULL and whose num_value is exactly −2^63 is served as
+/// (ruling RC2).
+///
+///
+/// Faults. As in the SQL Server data source: a database / bootstrap exception is recorded in
+/// and rethrown, failing only that call. Client-request errors — an unsupported
+/// aggregate () and a processed window over
+/// intervals () — are thrown before
+/// any I/O and are not counted as backend failures.
+///
+///
+public sealed class QuestDbHistorianDataSource : IHistorianDataSource
+{
+ /// Most AtTime request times per statement (two UNION ALL branches each; QuestDB 10.0.1 refuses more than 126 branches).
+ public const int AtTimeChunkSize = 50;
+
+ /// as a double (−2^63, exact): what an Int64 sample of that value leaves in num_value.
+ internal const double LongNullSentinelAsDouble = -9223372036854775808d;
+
+ private const string SampleColumns = "ts, kind, num_value, int_value, str_value, status, server_ts";
+
+ private readonly QuestDbConnection _conn;
+ private readonly QuestDbSchemaBootstrapper _bootstrap;
+ private readonly QuestDbTagCatalog _catalog;
+ private readonly ILogger _log;
+ private readonly TimeProvider _time;
+ private readonly HistorianHealthCounters _health = new();
+
+ /// Creates the data source over the provider's shared connection, bootstrapper and tag catalog.
+ /// Connection helper.
+ /// Schema bootstrapper (shared with the other QuestDB seams).
+ /// Tag catalog (reads use only its read-only ).
+ /// Optional logger.
+ /// Clock for "now" (AtTime beyond the last sample, server timestamps); system by default.
+ internal QuestDbHistorianDataSource(
+ QuestDbConnection conn, QuestDbSchemaBootstrapper bootstrap, QuestDbTagCatalog catalog, ILogger? log = null,
+ TimeProvider? timeProvider = null)
+ {
+ _conn = conn ?? throw new ArgumentNullException(nameof(conn));
+ _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap));
+ _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
+ _log = log ?? NullLogger.Instance;
+ _time = timeProvider ?? TimeProvider.System;
+ }
+
+ // ------------------------------------------------------------------------------------------ Raw
+
+ ///
+ ///
+ /// Forward (start < end): [start, end) ascending. Reverse (end < start):
+ /// (end, start] descending. start == end: the sample exactly at that (rounded) time.
+ /// 0 is "unbounded" (); larger values clamp.
+ /// At most top samples, ContinuationPoint always null (R8): the node manager pages by re-reading
+ /// from the last returned timestamp inclusive, which the (ts, tag) dedup key makes exact.
+ ///
+ public async Task ReadRawAsync(
+ string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fullReference);
+ var top = maxValuesPerNode == 0 ? int.MaxValue : (int)Math.Min(maxValuesPerNode, int.MaxValue);
+ try
+ {
+ var samples = new List();
+ if (await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false))
+ {
+ var start = QuestDbConnection.Round(startUtc);
+ var end = QuestDbConnection.Round(endUtc);
+ string where, order;
+ if (start == end) { where = "ts = $2 AND ts = $3"; order = "ASC"; }
+ else if (start < end) { where = "ts >= $2 AND ts < $3"; order = "ASC"; }
+ else { where = "ts > $2 AND ts <= $3"; order = "DESC"; }
+ var (lo, hi) = start <= end ? (start, end) : (end, start);
+
+ await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var cmd = _conn.CreateCommand(conn,
+ $"SELECT {SampleColumns} FROM {_conn.SampleTable} " +
+ $"WHERE tag = $1 AND {where} AND NOT deleted ORDER BY ts {order} LIMIT $4");
+ QuestDbConnection.BindText(cmd, fullReference);
+ QuestDbConnection.BindTimestamp(cmd, lo);
+ QuestDbConnection.BindTimestamp(cmd, hi);
+ cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = top });
+ await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ var now = _time.GetUtcNow().UtcDateTime;
+ while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ var s = ReadStored(reader, 0);
+ samples.Add(new DataValueSnapshot(s.Value.ToObject(), s.Status, s.TimeUtc, ReadServerTime(reader, 6, now)));
+ }
+ }
+
+ RecordSuccess();
+ return new HistoryReadResult(samples, ContinuationPoint: null);
+ }
+ catch (Exception ex)
+ {
+ RecordFailure(ex);
+ throw;
+ }
+ }
+
+ // ------------------------------------------------------------------------------------------ AtTime
+
+ ///
+ ///
+ /// Each distinct (rounded) requested time picks up its previous-or-equal and next live sample through two
+ /// LIMIT 1 seeks, UNION ALL-ed, at most times per statement (spec §3,
+ /// T0 Q9). then decides each value exactly as for SQL Server (exact hit,
+ /// no previous ⇒ BadNoData, inside an outage, held before an outage, linear, stepped, after now). The
+ /// result matches the request in length and order (duplicates included), each entry stamped at its rounded
+ /// time. Unknown tag ⇒ every entry BadNoData.
+ ///
+ public async Task ReadAtTimeAsync(
+ string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fullReference);
+ ArgumentNullException.ThrowIfNull(timestampsUtc);
+ try
+ {
+ var now = _time.GetUtcNow().UtcDateTime;
+ var rounded = timestampsUtc.Select(QuestDbConnection.Round).ToArray();
+ var byTicks = new Dictionary(rounded.Length);
+ if (rounded.Length > 0 && await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false))
+ {
+ var distinct = rounded.Distinct().ToList();
+ await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
+ for (var offset = 0; offset < distinct.Count; offset += AtTimeChunkSize)
+ {
+ var chunk = distinct.GetRange(offset, Math.Min(AtTimeChunkSize, distinct.Count - offset));
+ var prev = new StoredSample?[chunk.Count];
+ var next = new StoredSample?[chunk.Count];
+ var prevServer = new DateTime?[chunk.Count];
+
+ await using (var cmd = _conn.CreateCommand(conn, AtTimeSql(_conn.SampleTable, chunk.Count)))
+ {
+ QuestDbConnection.BindText(cmd, fullReference);
+ foreach (var t in chunk) QuestDbConnection.BindTimestamp(cmd, t);
+ await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ var k = Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture);
+ var dir = Convert.ToInt32(reader.GetValue(1), CultureInfo.InvariantCulture);
+ var s = ReadStored(reader, 2);
+ if (dir == 0)
+ {
+ prev[k] = s;
+ prevServer[k] = reader.IsDBNull(8) ? null : QuestDbConnection.ReadUtc(reader, 8);
+ }
+ else
+ {
+ next[k] = s;
+ }
+ }
+ }
+
+ for (var k = 0; k < chunk.Count; k++)
+ {
+ var t = chunk[k];
+ var serverTime = prev[k] is { } ps && ps.TimeUtc == t ? prevServer[k] ?? now : now;
+ byTicks[t.Ticks] = AtTimeEvaluator.Evaluate(t, prev[k], next[k], serverTime, now);
+ }
+ }
+ }
+
+ var result = new DataValueSnapshot[rounded.Length];
+ for (var i = 0; i < result.Length; i++)
+ {
+ var t = rounded[i];
+ result[i] = byTicks.TryGetValue(t.Ticks, out var v)
+ ? v
+ : new DataValueSnapshot(null, HistorianStatusCodes.BadNoData, t, now);
+ }
+
+ RecordSuccess();
+ return new HistoryReadResult(result, ContinuationPoint: null);
+ }
+ catch (Exception ex)
+ {
+ RecordFailure(ex);
+ throw;
+ }
+ }
+
+ ///
+ /// The T0 Q9 statement for request times: $1 = tag, $(k+2) = time k.
+ /// Columns: req, dir (0 = previous-or-equal, 1 = next), then ts, kind, num_value, int_value, str_value,
+ /// status, server_ts. req/dir are generated integers, never user input.
+ ///
+ internal static string AtTimeSql(string sample, int count)
+ {
+ if (count is < 1 or > AtTimeChunkSize) throw new ArgumentOutOfRangeException(nameof(count));
+ var sb = new StringBuilder();
+ for (var k = 0; k < count; k++)
+ {
+ var p = "$" + (k + 2).ToString(CultureInfo.InvariantCulture);
+ var req = k.ToString(CultureInfo.InvariantCulture);
+ if (k > 0) sb.Append(" UNION ALL ");
+ sb.Append("SELECT * FROM (SELECT ").Append(req).Append(" AS req, 0 AS dir, ").Append(SampleColumns)
+ .Append(" FROM ").Append(sample).Append(" WHERE tag = $1 AND ts <= ").Append(p)
+ .Append(" AND NOT deleted ORDER BY ts DESC LIMIT 1)");
+ sb.Append(" UNION ALL ");
+ sb.Append("SELECT * FROM (SELECT ").Append(req).Append(" AS req, 1 AS dir, ").Append(SampleColumns)
+ .Append(" FROM ").Append(sample).Append(" WHERE tag = $1 AND ts > ").Append(p)
+ .Append(" AND NOT deleted ORDER BY ts ASC LIMIT 1)");
+ }
+ return sb.ToString();
+ }
+
+ // ------------------------------------------------------------------------------------------ Events
+
+ ///
+ ///
+ /// [start, end) by event time (a reversed window is swapped), ordered by (ts, event_id) ascending.
+ /// <= 0 or ⇒ unbounded. Otherwise
+ /// maxEvents + 1 rows are fetched; when the extra row arrives it is dropped and the continuation point is
+ /// the UTF-8 of for that first not-returned event.
+ ///
+ public async Task ReadEventsAsync(
+ string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
+ {
+ // No stored event can carry a source longer than the limit, so an over-long filter matches nothing.
+ if (sourceName is not null && sourceName.Length > HistorianTagNames.MaxLength)
+ return new HistoricalEventsResult([], null);
+ try
+ {
+ await _bootstrap.EnsureAsync(cancellationToken).ConfigureAwait(false);
+ var start = QuestDbConnection.Round(startUtc);
+ var end = QuestDbConnection.Round(endUtc);
+ var (lo, hi) = start <= end ? (start, end) : (end, start);
+ var bounded = maxEvents > 0 && maxEvents < int.MaxValue;
+ var sql =
+ $"SELECT event_id, source, ts, received_utc, message, severity FROM {_conn.EventTable} " +
+ "WHERE ts >= $1 AND ts < $2" +
+ (sourceName is null ? "" : " AND source = $3") +
+ " ORDER BY ts ASC, event_id ASC" +
+ (bounded ? (sourceName is null ? " LIMIT $3" : " LIMIT $4") : "");
+
+ await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var cmd = _conn.CreateCommand(conn, sql);
+ QuestDbConnection.BindTimestamp(cmd, lo);
+ QuestDbConnection.BindTimestamp(cmd, hi);
+ if (sourceName is not null) QuestDbConnection.BindText(cmd, sourceName);
+ if (bounded) QuestDbConnection.BindLong(cmd, (long)maxEvents + 1);
+
+ var events = new List();
+ byte[]? continuation = null;
+ await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ var eventTime = QuestDbConnection.ReadUtc(reader, 2);
+ var e = new HistoricalEvent(
+ reader.IsDBNull(0) ? "" : reader.GetString(0),
+ reader.IsDBNull(1) ? null : reader.GetString(1),
+ eventTime,
+ reader.IsDBNull(3) ? eventTime : QuestDbConnection.ReadUtc(reader, 3),
+ reader.IsDBNull(4) ? null : reader.GetString(4),
+ HistoricalEventRows.ClampSeverity(reader.IsDBNull(5) ? 0 : reader.GetInt32(5)));
+ if (bounded && events.Count == maxEvents)
+ {
+ continuation = Encoding.UTF8.GetBytes(EventContinuation.Encode(e.EventTimeUtc, e.EventId));
+ break;
+ }
+ events.Add(e);
+ }
+
+ RecordSuccess();
+ return new HistoricalEventsResult(events, continuation);
+ }
+ catch (Exception ex)
+ {
+ RecordFailure(ex);
+ throw;
+ }
+ }
+
+ // ------------------------------------------------------------------------------------------ Processed
+
+ ///
+ ///
+ /// The (rounded) window is planned by : bucket i =
+ /// [start + i·interval, start + (i+1)·interval), start-inclusive / end-exclusive, the last bucket truncated
+ /// at end, interval <= 0 ⇒ one bucket, start == end ⇒ none, a reversed window computed forward
+ /// and returned descending, at most buckets. One statement per node returns
+ /// the statistics of each non-empty bucket (bucket index
+ /// (cast(ts AS LONG)*10 − StartUnixTicks) / IntervalTicks, never SAMPLE BY);
+ /// produces each value and fills the
+ /// empty buckets with BadNoData.
+ ///
+ /// The aggregate is not one of the thirteen served.
+ /// The window holds more than intervals.
+ public async Task ReadProcessedAsync(
+ string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
+ HistoryAggregateType aggregate, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fullReference);
+ var family = AggregateFamilies.FamilyOf(aggregate) ?? throw new HistoryAggregateNotSupportedException(aggregate);
+ var plan = BucketPlan.Create(QuestDbConnection.Round(startUtc), QuestDbConnection.Round(endUtc), interval);
+
+ try
+ {
+ var now = _time.GetUtcNow().UtcDateTime;
+ var evaluated = new Dictionary();
+ if (plan.BucketCount > 0 && await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false))
+ {
+ var sample = _conn.SampleTable;
+ var sql = family switch
+ {
+ AggregateFamily.Simple => QuestDbAggregateQueries.Simple(sample),
+ AggregateFamily.StartEnd => QuestDbAggregateQueries.StartEnd(sample),
+ _ => QuestDbAggregateQueries.ActualTime(sample, aggregate == HistoryAggregateType.MaximumActualTime),
+ };
+ await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var cmd = _conn.CreateCommand(conn, sql);
+ QuestDbConnection.BindLong(cmd, plan.StartUnixTicks);
+ QuestDbConnection.BindLong(cmd, plan.IntervalTicks);
+ QuestDbConnection.BindText(cmd, fullReference);
+ QuestDbConnection.BindTimestamp(cmd, plan.StartUtc);
+ QuestDbConnection.BindTimestamp(cmd, plan.EndUtc);
+ await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ var row = ReadBucket(reader, family);
+ evaluated[row.Bucket] = AggregateEvaluator.Evaluate(aggregate, row, plan.BucketStart(row.Bucket), now);
+ }
+ }
+
+ var result = plan.Assemble(evaluated, bucketStart => AggregateEvaluator.NoData(bucketStart, now));
+
+ RecordSuccess();
+ return new HistoryReadResult(result, ContinuationPoint: null);
+ }
+ catch (Exception ex)
+ {
+ RecordFailure(ex);
+ throw;
+ }
+ }
+
+ private static BucketRow ReadBucket(NpgsqlDataReader reader, AggregateFamily family)
+ {
+ static double? D(NpgsqlDataReader r, int i) => r.IsDBNull(i) ? null : r.GetDouble(i);
+ static int N(NpgsqlDataReader r, int i) => r.IsDBNull(i) ? 0 : Convert.ToInt32(r.GetValue(i), CultureInfo.InvariantCulture);
+ // A bucket without a usable sample has NULL times but kind 0 (BYTE has no NULL): absence is keyed on the time.
+ static HistorianValue V(NpgsqlDataReader r, int i) =>
+ r.IsDBNull(i + 4)
+ ? HistorianValue.Empty
+ : ToValue(QuestDbConnection.ReadKind(r, i), D(r, i + 1), r.IsDBNull(i + 2) ? null : r.GetInt64(i + 2),
+ r.IsDBNull(i + 3) ? null : r.GetString(i + 3));
+
+ var row = new BucketRow
+ {
+ Bucket = Convert.ToInt64(reader.GetValue(0), CultureInfo.InvariantCulture),
+ Total = N(reader, 1),
+ Usable = N(reader, 2),
+ Good = N(reader, 3),
+ Bad = N(reader, 4),
+ NonNumeric = N(reader, 5),
+ };
+ var f = QuestDbAggregateQueries.FirstFamilyColumn;
+ switch (family)
+ {
+ case AggregateFamily.Simple:
+ row.Avg = D(reader, f);
+ row.Min = D(reader, f + 1);
+ row.Max = D(reader, f + 2);
+ row.Sum = D(reader, f + 3);
+ break;
+ case AggregateFamily.StartEnd:
+ row.First = V(reader, f);
+ row.Last = V(reader, f + 5);
+ break;
+ default:
+ row.Extreme = D(reader, f);
+ row.ExtremeTime = reader.IsDBNull(f + 1) ? null : QuestDbConnection.ReadUtc(reader, f + 1);
+ break;
+ }
+ return row;
+ }
+
+ // ------------------------------------------------------------------------------------------ Shared
+
+ ///
+ /// Reads ts, kind, num_value, int_value, str_value, status starting at and applies the
+ /// shared quality clamp ().
+ ///
+ private static StoredSample ReadStored(NpgsqlDataReader r, int o)
+ {
+ var value = ToValue(
+ QuestDbConnection.ReadKind(r, o + 1),
+ r.IsDBNull(o + 2) ? null : r.GetDouble(o + 2),
+ r.IsDBNull(o + 3) ? null : r.GetInt64(o + 3),
+ r.IsDBNull(o + 4) ? null : r.GetString(o + 4));
+ // status is always written; a NULL (only possible for a hand-made row) is served as "no data", never Good.
+ var status = r.IsDBNull(o + 5) ? HistorianStatusCodes.BadNoData : SampleColumnMapper.FromLongStatus(r.GetInt64(o + 5));
+ return StoredSample.FromStored(QuestDbConnection.ReadUtc(r, o), value, status);
+ }
+
+ ///
+ /// Rebuilds a value from its stored columns via the shared , after the
+ /// RC2 repair: QuestDB reads a LONG holding back as NULL, so an Int64 row with
+ /// a NULL int_value and a num_value of exactly −2^63 is that value.
+ ///
+ internal static HistorianValue ToValue(byte kind, double? num, long? integer, string? str)
+ {
+ if (kind == (byte)HistorianValueKind.Int64 && integer is null && num == LongNullSentinelAsDouble)
+ integer = long.MinValue;
+ return SampleColumnMapper.FromColumns(kind, num, integer, str);
+ }
+
+ private static DateTime ReadServerTime(NpgsqlDataReader r, int i, DateTime fallback) =>
+ r.IsDBNull(i) ? fallback : QuestDbConnection.ReadUtc(r, i);
+
+ /// Read-only tag lookup (R7; never creates a row); runs the schema bootstrap on a cache miss.
+ private Task ResolveAsync(string name, CancellationToken ct) => _catalog.ExistsAsync(name, ct);
+
+ // ------------------------------------------------------------------------------------------ Health
+
+ ///
+ public HistorianHealthSnapshot GetHealthSnapshot() => _health.Snapshot();
+
+ private void RecordSuccess() => _health.RecordSuccess();
+
+ private void RecordFailure(Exception ex)
+ {
+ // A cancelled read (deadline / client abort) is not a backend fault (the counters ignore it too).
+ if (ex is OperationCanceledException) return;
+ _health.RecordFailure(ex);
+ _log.LogDebug("QuestDB historian read failed ({ExceptionType})", ex.GetType().Name);
+ }
+
+ ///
+ public void Dispose()
+ {
+ // Connections are per call (pooled by the shared NpgsqlDataSource, owned by the provider); nothing to release.
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformanceBackend.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformanceBackend.cs
new file mode 100644
index 00000000..5dfc6f4c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformanceBackend.cs
@@ -0,0 +1,203 @@
+using System.Text;
+using Npgsql;
+using NpgsqlTypes;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
+using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
+
+///
+/// The QuestDB historian as a conformance backend: a fresh unique table prefix per instance (dropped on dispose),
+/// seeded by plain multi-row INSERTs (never through the writer or updater) followed by the WAL visibility wait.
+/// Storage resolution is 1 µs (10 ticks); is , and
+/// rounds the times it is given the same way before storing them.
+///
+internal sealed class QuestDbConformanceBackend : IConformanceBackend
+{
+ private const int SeedBatchRows = 1000;
+
+ private readonly QuestDbFixture _fx;
+ private readonly string _prefix;
+
+ private QuestDbConformanceBackend(QuestDbFixture fx, string prefix, QuestDbHistorianOptions options,
+ QuestDbConnection conn, QuestDbSchemaBootstrapper boot)
+ {
+ _fx = fx;
+ _prefix = prefix;
+ Options = options;
+ Conn = conn;
+ Boot = boot;
+ Wal = new QuestDbWalVisibility(conn, options.VisibilityTimeout);
+ }
+
+ /// The options of this instance's table set.
+ public QuestDbHistorianOptions Options { get; }
+
+ /// The connection helper.
+ public QuestDbConnection Conn { get; }
+
+ /// The schema bootstrapper (already run).
+ public QuestDbSchemaBootstrapper Boot { get; }
+
+ /// The WAL visibility wait.
+ public QuestDbWalVisibility Wal { get; }
+
+ /// Creates a backend over a fresh table prefix; skips the calling test when the gate is closed.
+ /// The shared fixture.
+ /// The backend.
+ public static async Task CreateAsync(QuestDbFixture fx) => await CreateConcreteAsync(fx);
+
+ /// As , typed (for QuestDB-specific tests that need the connection).
+ /// The shared fixture.
+ /// The backend.
+ public static async Task CreateConcreteAsync(QuestDbFixture fx)
+ {
+ fx.SkipIfUnavailable();
+ var prefix = fx.UniquePrefix();
+ var o = fx.Options(prefix);
+ var conn = new QuestDbConnection(o);
+ var boot = new QuestDbSchemaBootstrapper(conn, o);
+ await boot.EnsureAsync(CancellationToken.None);
+ return new QuestDbConformanceBackend(fx, prefix, o, conn, boot);
+ }
+
+ ///
+ public long ResolutionTicks => 10;
+
+ ///
+ public DateTime Round(DateTime t) => QuestDbConnection.Round(t);
+
+ /// A fresh tag catalog (fresh positive cache) over this table set.
+ public QuestDbTagCatalog NewCatalog() => new(Conn, Boot, Wal);
+
+ ///
+ public IHistorianDataSource CreateDataSource(TimeProvider? clock = null) =>
+ new QuestDbHistorianDataSource(Conn, Boot, NewCatalog(), timeProvider: clock);
+
+ ///
+ /// TODO(T7): build the T4 QuestDbHistorianValueWriter here once T4 is merged.
+ public IHistorianValueWriter CreateValueWriter(Func? utcNow = null) =>
+ throw new NotSupportedException("TODO(T7): the QuestDB value writer (T4) is not merged into this branch yet.");
+
+ ///
+ /// TODO(T7): build the T6 QuestDbHistorianDataUpdater here once T6 is merged.
+ public IHistorianDataUpdater CreateUpdater() =>
+ throw new NotSupportedException("TODO(T7): the QuestDB history updater (T6) is not merged into this branch yet.");
+
+ ///
+ ///
+ /// Times are rounded to the microsecond () before they are stored; the tag row is registered
+ /// through the catalog (data type 0, as the SQL Server seed does); every row is live (deleted = false);
+ /// server_ts = rounded time + 1 s. Waits for WAL visibility before returning.
+ ///
+ public async Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples)
+ {
+ await NewCatalog().EnsureAsync(tag, 0, CancellationToken.None);
+ await InsertRowsAsync(tag, samples.Select(s => (Round(s.T), s.V, s.Status, Deleted: false)).ToArray());
+ }
+
+ ///
+ /// Inserts raw rows (the tag must already be registered when reads should find it), including tombstones, then
+ /// waits for WAL visibility. A tombstone re-inserted at an existing (ts, tag) replaces that row (DEDUP).
+ ///
+ /// The tag.
+ /// Rows: time (already rounded by the caller or not — it is bound through BindTimestamp), value, status, deleted.
+ public async Task InsertRowsAsync(string tag, (DateTime T, HistorianValue V, uint Status, bool Deleted)[] rows)
+ {
+ if (rows.Length == 0) return;
+ await using (var c = await Conn.OpenAsync(CancellationToken.None))
+ {
+ for (var offset = 0; offset < rows.Length; offset += SeedBatchRows)
+ {
+ var batch = rows.Skip(offset).Take(SeedBatchRows).ToArray();
+ var sql = new StringBuilder(
+ $"INSERT INTO {Conn.SampleTable} (ts, tag, kind, num_value, int_value, str_value, status, deleted, server_ts) VALUES ");
+ await using var cmd = Conn.CreateCommand(c, "");
+ var n = 0;
+ for (var i = 0; i < batch.Length; i++)
+ {
+ var (t, v, status, deleted) = batch[i];
+ var col = SampleColumnMapper.ToColumns(v);
+ if (i > 0) sql.Append(", ");
+ sql.Append('(');
+ for (var j = 0; j < 9; j++) sql.Append(j == 0 ? "" : ", ").Append('$').Append(++n);
+ sql.Append(')');
+ QuestDbConnection.BindTimestamp(cmd, t);
+ QuestDbConnection.BindText(cmd, tag);
+ QuestDbConnection.BindByte(cmd, col.ValueKind);
+ cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Double, Value = (object?)col.NumValue ?? DBNull.Value });
+ cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Bigint, Value = (object?)col.IntValue ?? DBNull.Value });
+ QuestDbConnection.BindText(cmd, col.StrValue);
+ QuestDbConnection.BindLong(cmd, SampleColumnMapper.ToLongStatus(status));
+ cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Boolean, Value = deleted });
+ QuestDbConnection.BindTimestamp(cmd, Round(t).AddSeconds(1));
+ }
+ cmd.CommandText = sql.ToString();
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+ await Wal.WaitAsync(Conn.SampleTable, CancellationToken.None);
+ }
+
+ /// Runs a statement against this table set (test-only escape hatch for hand-made rows), then waits for WAL visibility of the sample table.
+ /// The statement; parameters are bound positionally by .
+ /// Binds the parameters.
+ public async Task ExecuteAsync(string sql, Action bind)
+ {
+ await using (var c = await Conn.OpenAsync(CancellationToken.None))
+ {
+ await using var cmd = Conn.CreateCommand(c, sql);
+ bind(cmd);
+ await cmd.ExecuteNonQueryAsync();
+ }
+ await Wal.WaitAsync(Conn.SampleTable, CancellationToken.None);
+ }
+
+ ///
+ public async Task> ReadBackAsync(string tag)
+ {
+ await using var c = await Conn.OpenAsync(CancellationToken.None);
+ await using var cmd = Conn.CreateCommand(c,
+ $"SELECT ts, kind, num_value, int_value, str_value, status FROM {Conn.SampleTable} " +
+ "WHERE tag = $1 AND NOT deleted ORDER BY ts");
+ QuestDbConnection.BindText(cmd, tag);
+ var list = new List<(DateTime, HistorianValue, uint)>();
+ await using var r = await cmd.ExecuteReaderAsync();
+ while (await r.ReadAsync())
+ {
+ var v = QuestDbHistorianDataSource.ToValue(QuestDbConnection.ReadKind(r, 1),
+ r.IsDBNull(2) ? null : r.GetDouble(2), r.IsDBNull(3) ? null : r.GetInt64(3), r.IsDBNull(4) ? null : r.GetString(4));
+ // Verbatim: no read-side clamp. A NULL status cannot be written by any code path; it reads as the raw 0 bits.
+ var status = r.IsDBNull(5) ? 0u : SampleColumnMapper.FromLongStatus(r.GetInt64(5));
+ list.Add((QuestDbConnection.ReadUtc(r, 0), v, status));
+ }
+ return list;
+ }
+
+ ///
+ public async Task TagExistsAsync(string tag)
+ {
+ await using var c = await Conn.OpenAsync(CancellationToken.None);
+ await using var cmd = Conn.CreateCommand(c, $"SELECT count() FROM {Conn.TagTable} WHERE name = $1");
+ QuestDbConnection.BindText(cmd, tag);
+ return Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0;
+ }
+
+ ///
+ public async Task TagDataTypeAsync(string tag)
+ {
+ await using var c = await Conn.OpenAsync(CancellationToken.None);
+ await using var cmd = Conn.CreateCommand(c, $"SELECT data_type FROM {Conn.TagTable} WHERE name = $1 LIMIT 1");
+ QuestDbConnection.BindText(cmd, tag);
+ await using var r = await cmd.ExecuteReaderAsync();
+ return await r.ReadAsync() ? (DriverDataType)QuestDbConnection.ReadKind(r, 0) : null;
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await Conn.DisposeAsync();
+ await _fx.DropPrefixAsync(_prefix); // early; the fixture also drops every prefix it handed out
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/ReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/ReadTests.cs
new file mode 100644
index 00000000..d0f29d5a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/ReadTests.cs
@@ -0,0 +1,266 @@
+using System.Text;
+using Npgsql;
+using NpgsqlTypes;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
+using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
+
+///
+/// QuestDB-specific read behaviour (the shared Raw/AtTime/Processed literals run through the conformance suite):
+/// tombstones, the pre-1970 / whole-range bucket math, reverse paging at one value per page, QuestDB's LONG NULL
+/// sentinel, the kind-decides-the-value rule, AtTime chunking, Events and failure accounting.
+///
+[Collection(QuestDbCollection.Name)]
+public sealed class ReadTests(QuestDbFixture fx) : IAsyncLifetime
+{
+ private static readonly CancellationToken Ct = CancellationToken.None;
+
+ private const uint Good = ConformanceData.Good;
+ private const uint Bad = ConformanceData.Bad;
+ private const uint GoodCalc = 0x00000401u;
+ private const uint UncertainCalc = 0x40A40401u;
+ private const uint UncertainInterpolated = 0x40A40402u;
+ private const uint GoodInterpolated = 0x00000402u;
+ private const uint NoDataCalc = 0x809B0401u;
+
+ private QuestDbConformanceBackend _b = null!;
+
+ private static DateTime At(double s) => ConformanceData.At(s);
+
+ public async ValueTask InitializeAsync() => _b = await QuestDbConformanceBackend.CreateConcreteAsync(fx);
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_b is not null) await _b.DisposeAsync();
+ }
+
+ private static double?[] Values(HistoryReadResult r) => r.Samples.Select(s => (double?)s.Value).ToArray();
+
+ /// What T6's DeleteAtTime writes: the same (ts, tag), deleted = true, value columns NULL, status BadNoData.
+ private Task TombstoneAsync(string tag, DateTime t) =>
+ _b.InsertRowsAsync(tag, [(t, HistorianValue.Empty, HistorianStatusCodes.BadNoData, true)]);
+
+ [Fact]
+ public async Task Tombstoned_rows_are_invisible_to_raw_attime_and_processed()
+ {
+ await ConformanceData.SeedAAsync(_b); // +0=10, +10=20, +20=Bad 30, +30=40, +40=50
+ await TombstoneAsync("A", At(10)); // DEDUP replaces the +10 row with a tombstone
+ (await _b.ReadBackAsync("A")).Count.ShouldBe(4);
+ var src = _b.CreateDataSource();
+
+ var raw = await src.ReadRawAsync("A", At(0), At(100), 0, Ct);
+ Values(raw).ShouldBe([10d, 30d, 40d, 50d]);
+ raw.Samples.Select(s => s.StatusCode).ShouldBe([Good, Bad, Good, Good]);
+
+ // +10 is no longer an exact hit: previous is +0 = 10 (Good), next is the Bad +20 ⇒ held, Uncertain.
+ var at = (await src.ReadAtTimeAsync("A", [At(10), At(5)], Ct)).Samples;
+ at.Select(s => s.Value).ShouldBe([(object?)10d, 10d]);
+ at.ShouldAllBe(s => s.StatusCode == UncertainInterpolated);
+
+ // B0 [0,20) holds only {10}: Good, count 1. A visible tombstone would be a Bad sample ⇒ Uncertain, PercentBad 50.
+ async Task B0(HistoryAggregateType a) =>
+ (await src.ReadProcessedAsync("A", At(0), At(80), TimeSpan.FromSeconds(20), a, Ct)).Samples[0];
+ var avg = await B0(HistoryAggregateType.Average);
+ avg.Value.ShouldBe(10d);
+ avg.StatusCode.ShouldBe(GoodCalc);
+ (await B0(HistoryAggregateType.Count)).Value.ShouldBe(1);
+ (await B0(HistoryAggregateType.PercentBad)).Value.ShouldBe(0d);
+ (await B0(HistoryAggregateType.End)).Value.ShouldBe(10d);
+ }
+
+ [Fact]
+ public async Task A_whole_range_processed_read_from_1601_to_9999_with_interval_zero_does_not_overflow()
+ {
+ await ConformanceData.SeedAAsync(_b);
+ var src = _b.CreateDataSource();
+ var start = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var end = new DateTime(9999, 12, 31, 0, 0, 0, DateTimeKind.Utc);
+ foreach (var agg in new[] { HistoryAggregateType.Average, HistoryAggregateType.Start, HistoryAggregateType.MaximumActualTime })
+ {
+ var s = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, agg, Ct)).Samples.Single();
+ s.StatusCode.ShouldBe(UncertainCalc, agg.ToString());
+ }
+ (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Average, Ct)).Samples.Single().Value.ShouldBe(30d);
+ (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Start, Ct)).Samples.Single().Value.ShouldBe(10d);
+ var max = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.MaximumActualTime, Ct)).Samples.Single();
+ max.Value.ShouldBe(50d);
+ max.SourceTimestampUtc.ShouldBe(_b.Round(At(40)));
+ // The extreme DateTime range: MinValue/MaxValue are nudged (never bound as ±infinity, which would match nothing).
+ (await src.ReadProcessedAsync("A", DateTime.MinValue, DateTime.MaxValue, TimeSpan.Zero, HistoryAggregateType.Total, Ct))
+ .Samples.Single().Value.ShouldBe(120d);
+ // A pre-1970 window holds no data and is not an error.
+ (await src.ReadProcessedAsync("A", start, new DateTime(1969, 1, 1, 0, 0, 0, DateTimeKind.Utc), TimeSpan.FromDays(3650),
+ HistoryAggregateType.Count, Ct)).Samples.ShouldAllBe(x => x.StatusCode == NoDataCalc);
+ src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
+ }
+
+ ///
+ /// The node manager's reverse paging at NumValuesPerNode = 1 (OtOpcUaNodeManager + HistoryPaging): resume at
+ /// the last timestamp inclusive and trim it; when the trimmed page stalls on that timestamp, point-read it and step
+ /// one tick down. One tick below a µs-aligned time rounds to the previous µs, so the walk advances.
+ ///
+ [Fact]
+ public async Task A_reverse_paging_walk_with_one_value_per_page_returns_every_row()
+ {
+ await ConformanceData.SeedAAsync(_b);
+ var src = _b.CreateDataSource();
+ var end = At(-1);
+ DateTime start = At(100);
+ var resumed = false;
+ var emitted = new List