Skip to content

feat(query): add @aeye/query — LLM-friendly query language, runtime &…#180

Open
ClickerMonkey wants to merge 26 commits into
mainfrom
feat/aeye-query
Open

feat(query): add @aeye/query — LLM-friendly query language, runtime &…#180
ClickerMonkey wants to merge 26 commits into
mainfrom
feat/aeye-query

Conversation

@ClickerMonkey

Copy link
Copy Markdown
Owner

… SQL converter

New standalone package (packages/query):

  • Types/fields meta-model + class-based Expr system (one file per kind, Registry dispatch)
  • Relation-based joins, params, per-FieldType filters, functions (scalar/tabular/aggregate/window), array field type
  • In-memory runtime + base & postgres SQL converter (RLS/FLS, computed-field backing, named joins/LATERAL, three-valued NULL logic)
  • Dev-side Type backing (Access/Computed) so the conceptual model stays simple while fields map to columns/SQL/runtime fns
  • Graduated per-axis LLM schema depth + capability gating; strict/typed-args schemas
  • Transforms (drill-down, auto-paginate), cost estimation, type-from-data util
  • Examples folder + interactive CLI; README

100% test coverage (932 tests, base+postgres SQL + runtime), 0 typecheck errors, no any/unknown/casts. Wires packages/query into the workspace (root package.json, tsconfig.base.json).

Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS

ClickerMonkey and others added 26 commits July 1, 2026 20:17
… SQL converter

New standalone package (packages/query):
- Types/fields meta-model + class-based Expr system (one file per kind, Registry dispatch)
- Relation-based joins, params, per-FieldType filters, functions (scalar/tabular/aggregate/window), array field type
- In-memory runtime + base & postgres SQL converter (RLS/FLS, computed-field backing, named joins/LATERAL, three-valued NULL logic)
- Dev-side Type backing (Access/Computed) so the conceptual model stays simple while fields map to columns/SQL/runtime fns
- Graduated per-axis LLM schema depth + capability gating; strict/typed-args schemas
- Transforms (drill-down, auto-paginate), cost estimation, type-from-data util
- Examples folder + interactive CLI; README

100% test coverage (932 tests, base+postgres SQL + runtime), 0 typecheck errors, no any/unknown/casts.
Wires packages/query into the workspace (root package.json, tsconfig.base.json).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…GROUP BY / ORDER BY / HAVING

`{ kind:'output', name }` delegates to the referenced select item's expression
(looked up by its `as` / natural name): expands to portable SQL (base+postgres)
and re-evaluates correctly at runtime (group keys over the source row; ORDER BY /
HAVING over the group, incl. aggregate targets). Offered by the LLM schema only
in groupBy/orderBy/having positions (gated out of the general Expr union), with
output.unknown / output.aggregate / output.not-available validation and drill-down
expansion. README "Output references" section. 100% coverage maintained (960 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…generic

Tool and Prompt gain an optional `parse` that fully REPLACES Zod validation
(JSON.parse → parse → validate), so a caller can decode raw args into a built
domain object and return rich compiler-style errors instead of Zod's. When
`parse` is supplied its RETURN TYPE drives a new appended-last `TDecoded`
generic (default = the wire type) that types call/validate/metadataFn and the
structured-output value — the handler receives the decoded value with no cast.
Backward-compatible: absent `parse` ⇒ identical Zod behavior. Docs + guides +
reference updated to the 7-param signatures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
AIToolInput/AIPromptInput and the AI.tool()/AI.prompt() factories carry a
trailing TDecoded generic (appended-last-with-default) so a custom parse's
return type surfaces through the convenience layer; all existing callers
compile unchanged. Also fixes a pre-existing parse-ctx hydration/contravariance
mismatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…lf-describing, core Tool wiring

- Cross-type semantic pairing (bound-source query form) + numeric text-score
  (ts_rank); "top N by similarity/relevance" via ORDER BY score DESC LIMIT N.
- Function library expanded to 60+ (date/array/scalar/agg/window), renamed
  camelCase, per-dialect SQL (postgres native, base ANSI/degrade); an inline
  raw-literal arg mechanism for date-field selectors.
- e.* expression builder (returns real Expr instances; engine.evaluateExpr /
  exprToSQL; parseExpr passes an already-built Expr through).
- Search/semantic backing (hidden tsvector/pgvector vectorField) + backing
  alias-correctness (run factories receive the bound alias).
- `output` expr (reference a SELECT field in groupBy/orderBy/having); filters
  teardown of the op/clause machinery + query.filters() introspection.
- Self-describing: FunctionDef.instructions + static Expr INSTRUCTIONS +
  describeExprs/describeEngine + generated Type/Field descriptions; the CLI
  prompt is fully informed from the live engine.
- buildQueryTool returns a wired @aeye/core Tool (custom parse → built Query /
  QueryToolError, bypassing Zod).
- Cost-estimation accuracy (join fan-out + per-outer-row subquery multiplication).
- New aeye-query.md (LLM-facing) + README; 100% coverage gate (vitest+v8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
A custom `parse` can decode raw args into ANY value (a number, Date, array,
boolean, class instance, …), not just object/string — so `TDecoded`'s
constraint on Tool/Prompt and the ai.tool()/ai.prompt() wrappers is now
`extends unknown` (defaults = the wire type, unchanged). Component's `TInput`
is widened to match, since a component's input IS the decoded value. No runtime
change and no new casts (the existing wire-fallback bridge stayed valid). Tests
prove a primitive `parse` return (e.g. `() => 42`) types the handler input as
`number` end-to-end through Tool.parse, Prompt structured output, and the ai
wrappers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…te + field exprs/default)

Types declare insertable?/updatable?/deletable? (default true); fields declare
insertable?/updatable? and exprs?: {not|only} (narrow which expr kinds may
target them, bounded by the field type). FieldBacking.default (value or factory)
makes a field optional-on-insert and materializes it at runtime — no hasDefault;
a computed field defaults to non-insertable/updatable.

Enforced in BOTH layers: validation (insert/update/delete type+field readonly,
the insert-requiredness rule, field.expr-denied at direct-subject + operator
sites) AND schema-building (drop insert/update/delete kinds when no permitted
Type; filter into/type/from enums; paired-mode required-vs-optional insert
fields and updatable-only set; expr operand field enums honor not/only +
capability-gate a kind away when all fields exclude it). Runtime materializes
defaults (value/sync/async); describe surfaces the write-model tersely so the
self-describing prompt reflects it. aeye-query.md + README + 26 tests; 100%
coverage gate held.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…fied cases)

A separate `packages/query/integration/` harness — NOT run with the unit tests
and excluded from the coverage gate — that measures whether an LLM builds
CORRECT queries from natural-language requests:

- a 20-Type ERP model + deterministic, distractor-laden JSON data (a wrong query
  returns a wrong answer);
- 100 NL request cases across filter/join/aggregate/group/having/top-n/date/
  text/subquery/set-op/cte/window/pagination/write-model, each with a
  hand-written correct `oracle` query whose result IS the expected (derived from
  the data, never hand-guessed);
- run.ts: LLM mode (needs OPENROUTER_API_KEY; @aeye/ai + @aeye/openrouter;
  buildQueryTool + a describeEngine-informed prompt) generates → runs → compares
  → reports a pass rate; a key-free `--check` validates every oracle is valid/
  deterministic/discriminating; `--only`/`--category`/`--limit` for cheap
  per-case iteration;
- gitignored logs/latest.json (keyed by test id: emitted query + diagnostics +
  pass/fail + diff) and logs/failures.md for iterating on failures.

npm scripts `integration` / `integration:check`. Does not affect the unit
suite, the 100% coverage gate, or the src typecheck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
FieldBacking.relation lets a relation field declare its physical FK key
column(s) (hidden from the LLM) so the join ON is synthesized from them
(source.local = target.foreign, composite ANDed; foreign defaults to the
target's identity), plus a custom ON hook (expr/sql/run, access/computed-style
precedence) for dynamic predicates. Wired through every ON site — join SQL +
runtime applyHop, relation-walk/planner, relation-path, and TypeBacking.joins
relation specs — alias-correct, with JoinDef.and still ANDed on; the
inverseRelation has-many reuses the owning relation's FK. No backing ⇒ the
current name convention (byte-identical). Also fixes the has-many LEFT JOIN
anti-join case surfaced by the integration eval (a wrong convention FK produced
zero matches → all rows). 25 tests; 100% coverage gate held.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
… physical FKs)

The integration eval's 20-type ERP now models relations like a real schema:
clean LLM-facing relation fields (customer / order / product / category /
parent / creator / …) with the physical FK columns (customer_id / order_id /
category_id / parent_id / …, snake_case) HIDDEN in FieldBacking.relation.keys —
so the joins resolve via the backing, not the name convention. Includes a
composite-FK relation (salesReturn.invoice, keyed by order + customer) and a
custom relation.on.expr (salesOrderLine.order), both exercised through the
in-memory runtime by integration:check. Data regenerated (snake_case FKs, every
trap/distractor preserved, deterministic; also fixed a stale generator vs the
committed 4-level category tree); 100 → 101 cases; integration:check 101/101.
src suite / 100% coverage / typecheck unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…ed scope)

TypeBacking.defaultConditions ANDs a predicate into every read/update/delete for
a bound source BY DEFAULT (reusing the RLS injection path, so SQL + runtime
agree), and LIFTS for that source when a `without` field is referenced in a
CONDITION position (WHERE / HAVING / JOIN `and`) — not select/order/group.
`without` defaults to the fields the `where` predicate references; `ops` default
to select+update+delete (never insert). Alias-correct per bound source,
subquery-scoped, and ANDs alongside RLS (which is never suppressed).
describeType surfaces each condition + its reveal mechanism so the model knows
how to view the hidden rows.

e.g. a files table: `{ where: (a) => e.isNull(e.ref(a,'archivedAt')) }` → live
files by default; archived become visible the moment a filter references
archivedAt. 23 tests; 100% coverage gate held.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…ed, sensible-only)

TypeBacking.defaultOrder gives a Type a natural sort applied to an unsorted
SELECT's ORDER BY — { by: [{ by: Computed, dir?, nulls? }], applyTo }. The sort
key is a Computed (dual expr/sql/run), resolved alias-correct so the SQL ORDER BY
and the runtime sort agree. Applied only when sensible: FROM binds the backed
Type, the query has no explicit order, it is not aggregated (no groupBy / bare
aggregate) and not DISTINCT, and it is in scope per applyTo — 'result' (root
query + any SELECT with LIMIT/OFFSET) | 'paginated' (limit/offset only) | 'all'.
Root vs nested is an isRoot marker threaded from engine.run/toSQL (subqueries,
CTE bodies, and set-op branches are non-root). SELECT-only; pairs with
autoPaginate for deterministic pagination; describeType surfaces a terse note.
24 tests; 100% coverage gate held.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
A custom `parse` can decode into ANY value (a number, Date, array, class
instance, …), not just object/string — so the Tool/Prompt/Component generic
signatures + notes in aeye-core-tools.md, aeye-core-prompts.md, the core README,
and docs/public/llms.txt now show `TDecoded extends unknown` (and
`Component.TInput extends unknown`) instead of `extends object` / `object |
string`. Docs-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…map)

New src/json-source.ts: a positioned canonical JSON stringifier that maps every
node's structural path to its [start,end] char range in JSON.stringify(v,null,2).
Code.fromJson(value) registers those spans, so reportFor underlines the offending
value in the model's own JSON — for BOTH structural (zod) and semantic
(validateWalk) problems. flattenZodIssues descends the .or-folded union tree,
prunes branches that reject the `kind` discriminant, and keeps the deepest match
— collapsing hundreds of union sub-issues to the single offending value. Result:
a malformed model query renders as concise, localized `^^^` diagnostics instead
of zod union noise. 12 tests; 100% coverage gate held. (Phase A of the
own-the-parser plan.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…nostics)

Every generated schema node now carries an `aid` (the identifier used in
recursive schema generation) plus a per-schema directed zod error, so a
malformed query renders domain-specific text instead of zod's generic types —
still underlined at the offending JSON (Phase A):
  "left": "oops"     → expected an expression
  "op": "equals"     → expected a comparison operator: =, <>, <, <=, >, >=, like, notLike, ilike
  "args": "total"    → expected named arguments, an object of { argName: <expr> }, got a string
  "kind": "comparise"→ unknown expression kind `comparise` — did you mean `comparison`? (available: …)
  "limit": "three"   → expected a number or a param
  unknown Type name  → expected a registered Type name

New src/aids.ts: withAid(schema, aid, opts) (captures a zod error map surviving
.meta()/.describe()/lazy clones + stamps aid metadata), an aid→{label,noun}
registry, directedMessage() per issue code, and Levenshtein nearestKind for the
"did you mean". flattenZodIssues surfaces the union's aid-directed message.
Wire schema content unchanged except the aid metadata + friendlier text. zod
stays the validator (no owned-parser rewrite). 17 tests; 100% gate held.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…a $defs

PART A — the LLM problem report is now configurable: BuildQueryToolOptions.report
(FormatProblemsOptions) threads through parseQueryInput → reportFor →
formatProblems, so a dev sets contextLines / sectionHeaders / lineNumbers /
maxProblems (default unchanged: 2 context lines, headers + gutter on).

PART B — factor the largest repeated schema fragments into shared, aid-named
$defs (a per-generation SchemaCache reuses one instance so the converter emits
$def + $refs; withAid gains `id` to stamp meta.id, salted per generation to stay
unique in zod's global registry): per-Type field-name enums (Fields_<Type>, ~43
inlined copies → 1), param (~14→1), Limit, and typed function-args by signature
(104 builtins → ~35 distinct arg signatures). Minified z.toJSONSchema:
  open   21,295 → 20,235 (−5%)
  paired 105,609 → 95,543 (−9.5%)
The residual paired bulk is the ~100 genuinely-distinct per-function branches of
`typed` mode (irreducible without changing acceptance). Golden test pins 10 valid
+ 10 invalid queries to the SAME accept/reject + directed messages, plus a
size-threshold guard against re-inlining. 100% gate held; 6 new tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
Generalized the suggester (aids.ts): didYouMean(input, candidates, {max}) +
nearest() + suggestionBudget(len) = min(3, max(1, floor(len/3))), case-
insensitive, TYPO-only (never an unrelated word). Applied everywhere a name can
be wrong — appended to the existing directed, underlined messages:

- validation: unknown field → nearest of the Type's fields (field-ref, filters,
  relation-path, semantic + query-field, text-search/score, excluded,
  insert/update); unknown source → nearest bound source (new QueryScope.sources());
  unknown Type → nearest registered Type (insert/update/delete/source/relation-path);
  unknown relation hop → nearest relation; unknown function → nearest builtin of
  that shape; unknown named-arg → nearest param; unknown output ref → nearest
  select output;
- schema enums: a near-miss op / dir / nulls / function / Type / field value
  appends the nearest allowed value via directedMessage;
- (unknown expr/query kind already suggested — nearestKind now wraps nearest()).

e.g. op:"notlike" → "…, notLike, ilike — did you mean `notLike`?";
ref('u','nam') → "Type 'user' has no field 'nam'. — did you mean `name`?".
26 new tests; 100% coverage gate held.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…+ result)

Each eval case now declares `assert: Assertion[]` (ALL must hold) mixing
STRUCTURE with RESULT, so a case verifies the model built the right SHAPE — not
just that its rows happened to match. New integration/cases/assert.ts `a.*`:
structural (walk the model's queryDef, never run it) kind/from/joins(to)/
filtersOn/groupBy/having/aggregate(fn)/orderBy({by,dir})/limit(n)/offset/
distinct/window/setOp/cte/selects/custom/refused; result (lazy cached run)
resultOf(oracle,{match,tolerance})/rowCount/rows. `a.joins(to)`/`a.from` resolve
a relation hop's target Type via the registry so e.path(...) joins are
recognized.

EvalCase → { id, category, request, note, assert }. Runner evaluates every
assertion (case passes iff all pass) and logs per-assertion pass/fail + reason.
--check validates each resultOf oracle (valid/deterministic/non-degenerate) +
each refused sample fails; --only/--category/--limit kept. All 101 cases
rewritten to assert shape + result per request; a self-check (each oracle fed
back as the model query) confirms the 97 oracle-bearing cases pass their own
assertions. integration:check 101/101; src suite/coverage/typecheck unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…to bool

New condition.non-bool check (src/queries/_condition.ts checkBoolCondition):
a top-level predicate that isn't a param and doesn't resolve to bool is flagged
"Expected a boolean condition; got <category>." at its own path (underlined) —
matching how logical operands and case `when` are already checked (params
exempt/inferred). Wired into SelectQuery where[i] + having[i], the join `and`
(new QueryJoin.validateWalk — which also newly validates the and predicate's
inner exprs, previously unvalidated), UpdateQuery where, DeleteQuery where. A
comparison/in/between/exists/filters/bool-field predicate passes; a money
field-ref / literal number / arithmetic expr is flagged. 17 tests; 100% gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
Step 1 of replacing zod's structural error path with a query-owned parser that
each expr owns, never throws, and accumulates one-or-more shape problems per pass
(like validateWalk does for semantics). New src/shape/: Shape<T> combinators
(lit/str/num/int/bool/scalar/enumOf/obj/optional/list/exprRef) whose check() is
total — on bad input it records an aid-directed problem (+ didYouMean) and returns
the INVALID sentinel, never throwing. obj is fully typed with NO cast via an
inverted generic (F = built-value record, field map = { [K]: Shape<F[K]> }) + a
completeness type guard. registry.parseCheckedExpr(json: unknown, problems)
defensively dispatches (shape.not-object / missing-kind / unknown-kind + didYouMean)
to each class's new static SHAPE. Exemplars: comparison, field-ref, literal, param,
logical (with the not-arity cross-field rule). Parallel + unit-tested only — zod
still gates parseQueryInput; nothing flipped yet. 42 tests; 100% gate (1371 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…ision) + rewire eval to the query parser

The integration eval crashed EVERY case before reaching the model with
`Error: ID param_g2 already exists in the registry`.

Root cause: `SchemaCache` tagged the shared `$def` fragments (`Fields_*`,
`Args*`, `param`, `Limit`) with `.meta({ id })`, which registers into zod's
PROCESS-GLOBAL registry — and that registry THROWS on any duplicate id. Core's
`strictify` (openai.ts `convertResponseFormat`, used to build the model wire
schema) CLONES every node and re-applies its `.meta()`; the instant the wire
schema is strictified it re-registers those ids and collides. Salting the ids
per generation cannot help — strictify re-registers the SAME id, not a new one.

Fix: route the shared-fragment ids through a NEW process-LOCAL `z.registry()`
(`sharedIdRegistry` in aids.ts) instead of zod's global registry. `.meta({ aid })`
is unaffected — zod's `add()` only guards the `"id"` key, so re-registering an
aid never throws, and core's converter (which factors by `aid`, or by re-hitting
the same memoized instance for the aid-less `param`/`Args`) still emits a fully
factored model-facing wire schema. A plain `z.toJSONSchema` is told to read ids
from the local registry via its `metadata` option, so the `$def`/`$ref`
factoring — and the size win — is preserved without any id touching the global
registry. Verified via a no-network repro: `strictify(querySchema(erp))` no
longer throws, and the factored open/paired sizes stay well under the re-inline
bounds.

The factoring test now reads the shared ids from `sharedIdRegistry` and asserts
the `$def` names by PATTERN (`Fields_*` / `Args*` / `param(_g<n>)?` /
`Limit(_g<n>)?`), since the ids are salted per schema-generation; the size
thresholds are unchanged.

Also rewires the eval harness (`integration/run.ts`): the prompt now validates
model output through core's `parse` hook running the QUERY PARSER (`tool.parse`),
so the output-retry loop is driven by the package's own compiler-style,
aid-directed diagnostics instead of raw zod union noise — zod stays only as the
wire schema the model emits against.

typecheck 0; coverage 100% (1371 tests, --testTimeout=45000); examples exit 0;
integration:check 101/101; integration tsc 0. A small real eval
(openai/gpt-4o-mini) confirms the `already exists` crash is GONE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
Migrates the remaining non-query-referencing exprs to the zod-free structural
parser (after C1's foundation): binary, unary, is-null, between, in (list form),
case, array-op, aggregate, window, function-call, relation-path, output, excluded,
text-search, text-score. Each SHAPE.check(def).toJSON() deep-equals from(def),
accumulates every shape problem in one pass, never throws. New combinator
record(valueShape, aid) for named-arg maps (ordered Map, per-key accumulation) +
file-local array-value / text-query shapes matching existing behavior. Deferred to
C3 (need queryRef/sourceRef): subquery, exists, in-with-subquery, semantic,
tabular-function-call, filters (in-with-subquery records a shape.todo, list form
works). Still parallel — parseQueryInput's zod gate untouched (flips in C4). New
AIDs OutputName/Not/Distinct/CaseBranch/Order. 26 tests; 100% gate (1397 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
… (full tree)

Completes the zod-free structural parser's coverage. New combinators queryRef /
queryDefRef / sourceRef; dispatches registry.parseCheckedQuery + parseCheckedSource
(mirror parseCheckedExpr — never throw, shape.not-object/missing-kind/unknown-kind
+ didYouMean). Migrated: 8 query kinds (select, insert +onConflict, update, delete,
union/intersect/except, cte, expr) and 4 source kinds (type, aliased, subquery,
function); finished the 6 C2-deferred exprs (subquery, exists, in-with-subquery —
shape.todo gone, semantic, tabular-function-call, filters). Shared shapes in new
src/queries/_shape.ts + QueryOrder/QueryJoin SHAPE. parseCheckedExpr/Query/Source
now cover the WHOLE tree: a complex SELECT parses to a Query deep-equal to
registry.parseQuery, and a query with errors in ≥2 clauses surfaces them all in one
pass. Still parallel — parseQueryInput's zod gate flips in C4. 54 tests; 100% gate
(1451 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
The query-owned structural parser is now the ACTIVE gate. parseQueryInput no
longer runs schema.safeParse: it validates the envelope zod-free (missing `query`
→ shape.required), preserves the string-prose fallback, then
registry.parseCheckedQuery does the structural parse (accumulating, aid-directed,
didYouMean, never throws), and engine.validateQuery runs the semantics on a sound
tree. Deleted problemsFromZod / flattenZodIssues / FlatZodIssue / the discriminant
helpers / the safeParse gate. zod is now WIRE-SCHEMA-ONLY — querySchema/buildSchemas
still generate the schema the model emits against (tool.schema / compile / strict
mode); the golden test still proves the wire schema accepts/rejects the same
fixtures. A malformed query now surfaces ALL its structural problems in one pass,
each underlined + aid-directed, with zero zod in the diagnosis; unknown
names/types fall through to validateWalk's semantic messages (structure-vs-semantics
split). Tests updated to shape.* codes/messages; +1 accumulation test. 100% gate
(1452 tests); examples 0; integration:check 101/101.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…NSchema overflow)

array-op's toSchema wrapped its child `target` as `opts.Expr.describe(...)`. Calling
.describe() on a z.lazy CLONES it into a fresh wrapper carrying no `aid`, so a
converter that caches lazies by instance-identity + wrapper aid (e.g. core's
toJSONSchema) misses BOTH keys, re-evaluates the getter, and re-expands the whole
recursive Expr union — which contains array-op again — until the stack overflows.
Every other expr threads childExprSchema(opts.Expr) in bare (the shared cached
instance), so only array-op looped, and only when an array field made it appear in
the union (via exprKindApplicable). Use the child directly: a recursive child
converts to a bare $ref anyway, so the description was dropped regardless. Unblocks
toJSONSchema on any engine with an array field (e.g. the 20-Type integration ERP);
the eval now reaches the model instead of crashing. No core changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
…ch-all

A prompt can intercept each tool's result before it's handed to the model and
return what the model sees instead. Single discriminated handler:

  onToolResult?: (event: ToolResultEvent<TContext, TMetadata, TTools>) => unknown | Promise<unknown>

ToolResultEvent is a discriminated union over the tools tuple (mirrors
PromptToolOutput) so narrowing on event.tool types BOTH event.result and
event.args per tool; the default branch is the catch-all. The return value IS
what's presented to the model (serialized identically to today); return
event.result to pass through. Model-facing only — get('tools')/streamTools/the
raw toolOutput result are unchanged, and the toolOutput event gains a `toModel`
field carrying the presented value. Runs inside newToolExecution.run() on the
success path; a throwing handler cleanly converts the slot to a normal tool
error (pairing guarantee preserved); errored/suspended/interrupted/synthetic
markers bypass. v1 = success results only (error transforms are a trivial v2 via
a status discriminant). Fully type-safe: wrong tool-name / field access fail to
compile (@ts-expect-error tests). 14 tests; core suite 533 pass, typecheck 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aWFXmP2zN1wnX43EwRjBS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant