refactor(api): make a validated warehouse timestamp its own type - #630
Merged
Conversation
Two production error classes, one shape: a time string that isn't a valid
warehouse DateTime is spliced into SQL and only fails at ClickHouse.
MCP tools — normalizeTime returned unparseable input verbatim, so an
agent-supplied start_time of '2026-08-47:53' became
toDateTime('2026-08-47:53') and surfaced as an unhandled McpQueryError /
WarehouseMalformedQueryError. It now returns null; resolveTimeRange reports the
bad bound in `invalid` and substitutes the default window's bound, so st/et are
always well-formed even for a caller that doesn't check. The ten tools that
already guard `exceeded` now also return timeRangeInvalidResult, which names the
bad value and the accepted formats so the agent can retry.
v2 telemetry — parseWindow's `precision` defaulted to "millisecond", but
v2ListMetrics reads metric_catalog.Hour, a plain DateTime. Every call 500'd with
'Cannot parse string ...000 as DateTime' and a matching TYPE_MISMATCH. The
handler now asks for second precision, and `precision` is required so the next
rollup-backed handler can't inherit the unsafe default by omission.
Follow-up to #627, which fixed the two malformed-DateTime bugs but left the validation as hand-rolled checks each tool had to remember to run. Adds `WarehouseDateTime` — a branded Effect Schema for the canonical `YYYY-MM-DD HH:mm:ss` warehouse literal — and `WarehouseTimeInput`, which decodes every encoding a caller plausibly sends (warehouse shape, ISO-8601 with Z or an offset, fractional seconds, bare date) into it. The grammar is checked before parseability because `Date.parse` accepts "2026" and other partials, and the calendar fields are checked as written because V8 rolls 30 Feb over to 2 March rather than rejecting it — either would turn a typo into a confidently wrong window instead of an error. MCP tools now declare time bounds with `optionalTimeParam`, so a malformed bound fails at the parameter decode boundary with a message naming the offending value, the same pattern `optionalNumberParam` already established for numbers. `resolveTimeRange` accepts only the brand, so a tool that declares a bound as a plain string no longer produces a subtly wrong window at runtime — it fails to compile. That let #627's `normalizeTime`, `invalid` array and per-tool `timeRangeInvalidResult` guards be deleted outright. Also drops the unreachable NaN branch in the v2 `parseWindow`, which reported an unparseable `start_time` as "end_time must be later than start_time" — blaming the wrong parameter for a case the `Timestamp` schema already rejects upstream.
…e-datetime-params # Conflicts: # apps/api/src/mcp/lib/time.test.ts # apps/api/src/mcp/lib/time.ts # apps/api/src/mcp/tools/explore-attributes.ts # apps/api/src/mcp/tools/find-slow-traces.ts # apps/api/src/mcp/tools/get-service-top-operations.ts # apps/api/src/mcp/tools/list-metrics.ts # apps/api/src/mcp/tools/list-product-events.ts # apps/api/src/mcp/tools/mine-log-patterns.ts # apps/api/src/mcp/tools/query-funnel.ts # apps/api/src/mcp/tools/search-logs.ts # apps/api/src/mcp/tools/search-sessions.ts # apps/api/src/mcp/tools/search-traces.ts
🍁 Maple PR previewWarning Preview cleanup could not be confirmed. The Alchemy teardown outcome was Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #627. That PR fixed the two malformed-
DateTimebugs, but it fixed them with hand-rolled checks and aninvalidarray each tool had to remember to inspect — a guard that works until someone adds tool 58 and doesn't. This replaces that with a type.A validated timestamp becomes its own type
packages/query-engine/src/datetime.tsgains two schemas:WarehouseDateTime— branded, canonicalYYYY-MM-DD HH:mm:ssUTC. Safe to interpolate into aDateTimecomparison. It cannot be produced by string manipulation, only by decoding or bywarehouseDateTime(epochMs).WarehouseTimeInput— decodes every encoding a caller plausibly sends (warehouse shape, ISO-8601 withZor an offset, fractional seconds, a bare date) into the canonical form.Two things the validation does that a NaN check doesn't, both found by writing the tests:
Date.parse("2026")succeeds, as doesDate.parse("Aug 25 2026"). A NaN check alone lets a sheared timestamp through as a confidently wrong window.2026-02-30— it doesn't, it rolls over to 2 March. Month 13 is rejected, day overflow is not. Left to the parser a typo becomes silently wrong data, so the fields are validated directly (leap years included).Validation moves to the parameter boundary
MCP tools now declare bounds with
optionalTimeParaminstead ofoptionalStringParam, so a malformed value fails at decode — before any tool body runs — with a message naming the offending value and the accepted shapes. This is the patternoptionalNumberParamalready established, for the same caller: its doc comment citesinvestigation.hypothesis, which is the agent that sent2026-08-47:53.The default rendering for a failed pattern check is the raw regex source, which tells a model nothing about what to send instead, so the check builds its own message.
The type makes it unskippable
resolveTimeRangenow accepts onlyWarehouseDateTime. A tool that declares a time bound as a plain string doesn't get a subtly wrong window at runtime — it fails to compile. The typechecker found the one call site I'd missed (inspect-chart-data), which is the whole argument for doing it this way.That let #627's scaffolding be deleted outright:
normalizeTime, theinvalidarray,InvalidTimeBound,timeRangeInvalidMessage,timeRangeInvalidResult, and the ten per-tool guards.resolveTimeRangeis now total — it has no parsing to do and no malformed case to handle.One latent bug fixed in passing
The v2
parseWindowtestedNumber.isNaN(startMs) || Number.isNaN(endMs) || endMs <= startMsand reported all three as"end_time must be later than start_time."— so an unparseablestart_timeblamedend_time. The NaN branch is also unreachable: every call site passes a value theTimestampschema already validated at the HTTP boundary. Now it checks ordering only.Deliberately not changed
listMetricsQuerystill compares$.Hour.lte(param.dateTime("endTime"))without atoDateTime()wrapper, asymmetric with the.gtebeside it. Wrapping it would be belt-and-braces: the brand makes a fractional value unrepresentable at that call site, which is strictly stronger than defending against it in SQL, and the change would churn the SQL catalog baseline for no behaviour difference.Testing
apps/apiMCP + v2 routes: 539 passedpackages/query-engine: 1269 passeddatetime-schema.test.ts: 10 cases covering the accepted encodings, offset conversion,2026-08-47:53, calendar impossibilities, leap years on both sides, and the partialsDate.parsewould accepttime.test.tsrewritten to drive bounds throughoptionalTimeParam, so the parameter schema and the resolver are exercised on the same path production uses — plus a test pinning the published JSON schema to a plain string, since this file's history is one of parameters publishing as something the decoder then rejectsNeed help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.