Skip to content

fix(inputlayer-js): compile kg.query() to queries the engine accepts - #229

Merged
jsam merged 4 commits into
mainfrom
fm/il-fix-js-query
Oct 4, 2026
Merged

jsam merged 4 commits into
mainfrom
fm/il-fix-js-query

Conversation

@jsam

@jsam jsam commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Intent

Ok continue. (Context: the production sign-off review found a release blocker: the JavaScript SDK's kg.query() compiles every query to '?H <- body', which the engine parser rejects with 'Query cannot contain a rule definition'. Every documented query example in the JS SDK docs (js-sdk.mdx) fails; the integration tests avoid kg.query, so CI never saw it. Findings are fixed before release.)

What Changed

  • kg.query() no longer compiles to the ?Head <- body form, which the engine rejects with "Query cannot contain a rule definition". A new compileQueryPlan() builds the programs to run plus a description of the result's shape:
    • Plain queries become ?atom, body... and the SDK pulls the selected columns out of the rows the engine returns.
    • orderBy becomes a :asc/:desc annotation on the first atom, and the ordered relation is moved first.
    • Aggregates compile to a rule and a query in the same program, so the rule only exists for that request. Each aggregate gets its own column (CountId, AvgSalary), and topK expands to its passthrough and ordered columns.
    • OR splits run once per branch. The SDK merges the results, removes duplicates on the atom variables, then sorts and paginates them itself.
    • Non-equality join conditions such as e1.id != e2.id are kept as filters instead of being dropped.
    • An offset without a limit is applied on the client.
  • kg.debug() and kg.why() now read from the same query plan. why() runs a dedicated rule, finds result columns by position, and applies the query's ordering and paging to its rows, provenance and proof trees.
  • Added tests/docs-queries.integration.test.ts, which runs each query example in js-sdk.mdx against a live server. A new make js-test-live target builds and starts that server, and a new js-sdk-live job in the full suite runs it; npm test now skips every *integration.test.ts file. Two guide examples were fixed so they run: per-group topK now puts the columns to keep in passthrough, and the last-hour filter now compares against fn.timeSub(fn.timeNow(), …) instead of passing fn.withinLast() as the condition. Compiler unit tests were updated to match the new output.

Risk Assessment

✅ Low: The round-3 fix applies a lone offset in the single-program path of query(). It slices the projected rows after the engine has ordered them. It also slices row_provenance and recomputes rowCount. This matches pageOf, which why() and OR merges already use, so all three paths now give the same rows. The prior rounds' plan/why rework was checked and nothing new was found.

Testing

I built the release server and ran make js-test-live, which runs every js-sdk.mdx query example plus the debug/why/offset tests against a live engine: all 25 passed. I ran the same tests against the base-commit SDK: 24 of 25 failed with "Query cannot contain a rule definition (<-)", which confirms the blocker existed and is now fixed. An independent ad-hoc script on a separate scratch server also gave correct rows for: plain queries, joins, aggregates, ordering and paging, a lone offset (query() and why() agree), OR merges, aggregate debug plans, why() on second-relation columns, and aggregate limit. It also showed that no session rule was left behind. The scratch servers and temp dirs were removed and the worktree is clean.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A user runs each documented js-sdk.mdx kg.query() example (basic, filter, columns, joins, self-joins, computed, ordering/pagination, aggregations, topK, sessions, distance, temporal, derived relations… ✅ pass live js-test-live.log (make js-test-live: 25/25 passed)
Regression: the same documented queries on the pre-fix SDK are rejected by the engine with 'Query cannot contain a rule definition' ✅ pass live base-sdk-repro.log: 24 failed / 1 passed (only Raw IQL passes)
kg.debug() on an aggregate query returns a query plan instead of an error, and leaves no il_sdk_agg session rule behind ✅ pass live adhoc-transcript.txt 'debug aggregate' and '.session' -> 'No session data defined.'; js-test-live 'debug shows the plan of an aggregate query'
kg.why() returns the selected columns, including a second joined relation's column and a computed column, rather than the engine's raw .why columns ✅ pass live adhoc-transcript.txt 'why join second relation col' -> columns [Name, Floor]; js-test-live why tests
kg.why() on an aggregate with orderBy and limit 1 returns the same single group as kg.query() ✅ pass live adhoc-transcript.txt 'why agg limit 1' and 'query agg limit 1' both [[eng,2]]
Adversarial: offset without limit skips the first N ordered rows the same way in query(), why() and OR-merged queries ✅ pass live adhoc-transcript.txt lone-offset query and why both [[Cy,70],[Di,60]]; js-test-live offset test
Adversarial: OR where-clause merges both branches, and data containing parentheses/apostrophes does not break results ✅ pass live adhoc-transcript.txt 'OR dept=hr or salary>95' -> Di, Ann; filter returns "O'Neil (x)"
Evidence: Live js-sdk.mdx docs query suite on the fixed SDK (25/25 pass)
    Finished `release` profile [optimized] target(s) in 0.17s

added 87 packages, and audited 88 packages in 1s

19 packages are looking for funding
  run `npm fund` for details

2 moderate severity vulnerabilities

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.

 RUN  v3.2.7 ~/.no-mistakes/worktrees/9eea230c3aae/01M4335XPHXQS0NR0JT6TXB32X/packages/inputlayer-js

 ✓ tests/docs-queries.integration.test.ts (25 tests) 216ms

 Test Files  1 passed (1)
      Tests  25 passed (25)
   Start at  09:31:18
   Duration  679ms (transform 161ms, setup 0ms, collect 200ms, tests 216ms, environment 0ms, prepare 75ms)
Evidence: Same live suite on base SDK 4adce1e (24/25 fail with 'Query cannot contain a rule definition')

 RUN  v3.2.7 /tmp/il-base-363L/packages/inputlayer-js

 ❯ tests/docs-queries.integration.test.ts (25 tests | 24 failed) 172ms
   × js-sdk.mdx query examples > Basic Queries: all rows 7ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > Basic Queries: with a filter 2ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > Selecting Specific Columns 1ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > Joins 1ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > Self-Joins 1ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > Computed Columns 1ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > Ordering and Pagination 1ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > Aggregations: group by department with stats 1ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > Aggregations: topK per group 1ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > Working with Results 1ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > Query Plans 1ms
     → Debug error: Query debug failed: Invalid atom: ?Id, Name, Department, Salary, Active
   × js-sdk.mdx query examples > debug shows the plan of an aggregate query 1ms
     → Debug error: Query debug failed: Invalid atom: ?Department, count<Id>
   × js-sdk.mdx query examples > why returns the selected columns with a proof per row 1ms
     → Why error: Query execution failed: Invalid atom: ?Name, Salary
   × js-sdk.mdx query examples > why explains an aggregate query 1ms
     → Why error: Query execution failed: Invalid atom: ?Department, count<Id>
   × js-sdk.mdx query examples > why returns a column of the second joined relation 1ms
     → Why error: Query execution failed: Invalid atom: ?Name, Budget
   × js-sdk.mdx query examples > why returns a computed column 1ms
     → Why error: Query execution failed: Invalid atom: ?Name, Salary * 2
   × js-sdk.mdx query examples > an offset without a limit skips rows in query, why and OR merges alike 1ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > why orders and paginates like query 1ms
     → Why error: Query execution failed: Invalid atom: ?Name
   × js-sdk.mdx query examples > why orders and limits an aggregate query like query 1ms
     → Why error: Query execution failed: Invalid atom: ?Department, count<Id>
   ✓ js-sdk.mdx query examples > Raw IQL 2ms
   × js-sdk.mdx query examples > Derived Relations: querying a recursive rule 16ms
     → Program has 1 parse error(s)
   × js-sdk.mdx query examples > Sessions: session facts mix with persistent data 1ms
     → Failed to parse query: Query cannot contain a rule definition (<-). Register the rule first with + then query it with ?
   × js-sdk.mdx query examples > Distance Functions 1ms
     → Failed to parse query: Query cannot contain a rule definition (<-). Register the rule first with + then query it with ?
   × js-sdk.mdx query examples > Temporal Functions 1ms
     → Failed to parse query: Query cannot contain a rule definition (<-). Register the rule first with + then query it with ?
   × js-sdk.mdx query examples > OR conditions merge their branches 1ms
     → Failed to parse query: Query cannot contain a rule definition (<-). Register the rule first with + then query it with ?

⎯⎯⎯⎯⎯⎯ Failed Tests 24 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Basic Queries: all rows
QueryError: Program has 1 parse error(s)
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:336:20
 ❯ tests/docs-queries.integration.test.ts:120:20

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Basic Queries: with a filter
QueryError: Program has 1 parse error(s)
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:336:20
 ❯ tests/docs-queries.integration.test.ts:130:23

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Selecting Specific Columns
QueryError: Program has 1 parse error(s)
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:336:20
 ❯ tests/docs-queries.integration.test.ts:142:20

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Joins
QueryError: Program has 1 parse error(s)
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:336:20
 ❯ tests/docs-queries.integration.test.ts:156:20

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Self-Joins
QueryError: Program has 1 parse error(s)
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:336:20
 ❯ tests/docs-queries.integration.test.ts:174:20

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Computed Columns
QueryError: Program has 1 parse error(s)
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:336:20
 ❯ tests/docs-

... [7448 bytes truncated] ...

 KnowledgeGraph.why src/knowledge-graph.ts:606:20
 ❯ tests/docs-queries.integration.test.ts:375:17

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > why returns a computed column
QueryError: Why error: Query execution failed: Invalid atom: ?Name, Salary * 2
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.why src/knowledge-graph.ts:606:20
 ❯ tests/docs-queries.integration.test.ts:390:17

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > an offset without a limit skips rows in query, why and OR merges alike
QueryError: Program has 1 parse error(s)
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:336:20
 ❯ tests/docs-queries.integration.test.ts:411:13

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[17/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > why orders and paginates like query
QueryError: Why error: Query execution failed: Invalid atom: ?Name
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.why src/knowledge-graph.ts:606:20
 ❯ tests/docs-queries.integration.test.ts:428:17

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[18/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > why orders and limits an aggregate query like query
QueryError: Why error: Query execution failed: Invalid atom: ?Department, count<Id>
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.why src/knowledge-graph.ts:606:20
 ❯ tests/docs-queries.integration.test.ts:441:17

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[19/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Derived Relations: querying a recursive rule
QueryError: Program has 1 parse error(s)
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:336:20
 ❯ tests/docs-queries.integration.test.ts:466:20

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[20/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Sessions: session facts mix with persistent data
QueryError: Failed to parse query: Query cannot contain a rule definition (<-). Register the rule first with + then query it with ?
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:336:20
 ❯ tests/docs-queries.integration.test.ts:484:20

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[21/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Distance Functions
QueryError: Failed to parse query: Query cannot contain a rule definition (<-). Register the rule first with + then query it with ?
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:336:20
 ❯ tests/docs-queries.integration.test.ts:491:20

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[22/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Temporal Functions
QueryError: Failed to parse query: Query cannot contain a rule definition (<-). Register the rule first with + then query it with ?
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:336:20
 ❯ tests/docs-queries.integration.test.ts:506:20

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[23/24]⎯

 FAIL  tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > OR conditions merge their branches
QueryError: Failed to parse query: Query cannot contain a rule definition (<-). Register the rule first with + then query it with ?
 ❯ queryError src/connection.ts:438:10
    436|   if (response.code === 'outcome_unknown') return new OutcomeUnknownEr…
    437|   if (response.code === 'store_read_only') return new StoreReadOnlyErr…
    438|   return new QueryError(response.message, {
       |          ^
    439|     code: response.code,
    440|     validationErrors: response.validation_errors,
 ❯ Connection.readResult src/connection.ts:260:15
 ❯ Connection.execute src/connection.ts:240:14
 ❯ KnowledgeGraph.query src/knowledge-graph.ts:327:24
 ❯ tests/docs-queries.integration.test.ts:526:20

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[24/24]⎯


 Test Files  1 failed (1)
      Tests  24 failed | 1 passed (25)
   Start at  09:31:36
   Duration  638ms (transform 155ms, setup 0ms, collect 201ms, tests 172ms, environment 0ms, prepare 75ms)
Evidence: Ad-hoc live SDK transcript (query/why/debug/offset/OR/aggregate)
## all rows
["id","name","dept","salary"]
[[1,"Ann","eng",100],[2,"Bo","eng",80],[3,"Cy","ops",70],[4,"O'Neil (x)","ops",90],[5,"Di","hr",60]]
## filter salary>75 (string w/ paren in data)
["Name"]
[["Ann"],["Bo"],["O'Neil (x)"]]
## join dept floor
["Name","Floor"]
[["Ann",3],["Bo",3],["Cy",1],["O'Neil (x)",1],["Di",2]]
## agg count/avg by dept
["Dept","CountId","AvgSalary"]
[["eng",2,90],["hr",1,60],["ops",2,80]]
## orderBy salary desc limit 2
["Name","Salary"]
[["Ann",100],["O'Neil (x)",90]]
## lone offset 3 orderBy salary desc (query)
["Name","Salary"]
[["Cy",70],["Di",60]]
## lone offset 3 orderBy salary desc (why)
null
{"results":{"columns":["Name","Salary"],"rows":[["Cy",70],["Di",60]],"rowCount":2,"totalCount":5,"truncated":false,"executionTimeMs":0,"rowProvenance":["unknown","unknown"],"timingBreakdown":{"total_us":205,"parse_us":0,"sip_us":0,"magic_sets_us":0,"ir_build_us":0,"optimize_us":0,"shared_views_us":0,"rules":[{"rule_head":"query_execution","execution_us":183,"is_recursive":false,"workers":1},{"rule_head":"proof_tree_construction","execution_us":18,"is_recursive":false,"workers":1}]},"hasEphemeral":false,"ephemeralSources":[],"warnings":[]},"proofTrees":[{"nodes":{"n0":{"conclusion":{"args":["Cy",70,3,"ops"],"pred":"il_sdk_why"},"kind":"fact","source":"edb"}},"query":"il_sdk_why(Name, Salary, Id, Dept) <- emp(Id, Name, Dept, Salary)","roots":["n0"],"version":1},{"nodes":{"n0":{"conclusion":{"args":["Di",60,5,"hr"],"pred":"il_sdk_why"},"kind":"fact","source":"edb"}},"query":"il_sdk_why(Name, Salary, Id, Dept) <- emp(Id, Name, Dept, Salary)","roots":["n0"],"version":1}]}
## OR dept=hr or salary>95
["Name"]
[["Di"],["Ann"]]
## debug aggregate
null
{"iql":"il_sdk_agg(Dept, count<Id>) <- emp(Id, Name, Dept, Salary)","plan":"Query Plan:\n═══════════════════════════════════════════════════════════\n                    PIPELINE TRACE                          \n═══════════════════════════════════════════════════════════\n\n┌---------------------------------------------------------┐\n| PARSING                                                 |\n`---------------------------------------------------------┘\n  Rules parsed: 1\n\n  Rule 1: il_sdk_agg(Variable(\"Dept\"), Aggregate(Count, \"Id\")) <- emp(Variable(\"Id\"), Variable(\"Name\"), Variable(\"Dept\"), Variable(\"Salary\"))\n\n┌---------------------------------------------------------┐\n| IR CONSTRUCTION                                         |\n`---------------------------------------------------------┘\n  IR nodes: 2\n  Rules: 1\n\n  Rule 1 IR:\n    Aggregate[group_by=[2], aggs=[Count(0)]] -> [Dept, count_Id]\n      Scan(emp)[Id, Name, Dept, Salary]\n\n┌---------------------------------------------------------┐\n| OPTIMIZATION                                            |\n`---------------------------------------------------------┘\n  IR nodes: 2 -> 2 (0 eliminated)\n\n  Rule 1 Optimized IR:\n    Aggregate[group_by=[2], aggs=[Count(0)]] -> [Dept, count_Id]\n      Scan(emp)[Id, Name, Dept, Salary]\n\n═══════════════════════════════════════════════════════════\n\n\nOptimization passes:\n  - Join Planning (spanning tree reordering)\n  - SIP Rewriting (semijoin reduction)\n  - Subplan Sharing (common subexpression elimination)\n  - Boolean Specialization (semiring selection)\n  - Magic Sets (demand-driven recursion)\n  - Constant Specialization (demand-driven non-recursive rules)\n  - Basic Optimizations (identity elimination, filter simplification)"}
## why join second relation col
null
{"results":{"columns":["Name","Floor"],"rows":[["Ann",3],["Bo",3],["Cy",1],["Di",2],["O'Neil (x)",1]],"rowCount":5,"totalCount":5,"truncated":false,"executionTimeMs":0,"rowProvenance":["unknown","unknown","unknown","unknown","unknown"],"timingBreakdown":{"total_us":881,"parse_us":0,"sip_us":0,"magic_sets_us":0,"ir_build_us":0,"optimize_us":0,"shared_views_us":0,"rules":[{"rule_head":"query_execution","execution_us":860,"is_recursive":false,"workers":1},{"rule_head":"proof_tree_construction","execution_us":17,"is_recursive":false,"workers":1}]},"hasEphemeral":false,"ephemeralSources":[],"warnings":[]},"proofTrees":[{"nodes":{"n0":{"conclusion":{"args":["Ann",3,1,"eng",100],"pred":"il_sdk_why"},"kind":"fact","source":"edb"}},"query":"il_sdk_why(Name, Floor, Id, Dept, Salary) <- emp(Id, Name, Dept, Salary), dept(Dept, Floor)","roots":["n0"],"version":1},{"nodes":{"n0":{"conclusion":{"args":["Bo",3,2,"eng",80],"pred":"il_sdk_why"},"kind":"fact","source":"edb"}},"query":"il_sdk_why(Name, Floor, Id, Dept, Salary) <- emp(Id, Name, Dept, Salary), dept(Dept, Floor)","roots":["n0"],"version":1},{"nodes":{"n0":{"conclusion":{"args":["Cy",1,3,"ops",70],"pred":"il_sdk_why"},"kind":"fact","source":"edb"}},"query":"il_sdk_why(Name, Floor, Id, Dept, Salary) <- emp(Id, Name, Dept, Salary), dept(Dept, Floor)","roots":["n0"],"version":1},{"nodes":{"n0":{"conclusion":{"args":["Di",2,5,"hr",60],"pred":"il_sdk_why"},"kind":"fact","source":"edb"}},"query":"il_sdk_why(Name, Floor, Id, Dept, Salary) <- emp(Id, Name, Dept, Salary), dept(Dept, Floor)","roots":["n0"],"version":1},{"nodes":{"n0":{"conclusion":{"args":["O'Neil (x)",1,4,"ops",90],"pred":"il_sdk_why"},"kind":"fact","source":"edb"}},"query":"il_sdk_why(Name, Floor, Id, Dept, Salary) <- emp(Id, Name, Dept, Salary), dept(Dept, Floor)","roots":["n0"],"version":1}]}
## why agg limit 1
null
{"results":{"columns":["Dept","CountId"],"rows":[["eng",2]],"rowCount":1,"totalCount":3,"truncated":false,"executionTimeMs":0,"rowProvenance":["unknown"],"timingBreakdown":{"total_us":278,"parse_us":0,"sip_us":0,"magic_sets_us":0,"ir_build_us":0,"optimize_us":0,"shared_views_us":0,"rules":[{"rule_head":"query_execution","execution_us":264,"is_recursive":false,"workers":1},{"rule_head":"proof_tree_construction","execution_us":12,"is_recursive":false,"workers":1}]},"hasEphemeral":false,"ephemeralSources":[],"warnings":[]},"proofTrees":[{"nodes":{"n0":{"conclusion":{"args":["eng",2],"pred":"il_sdk_agg"},"kind":"fact","source":"edb"}},"query":"il_sdk_agg(Dept, count<Id>) <- emp(Id, Name, Dept, Salary)","roots":["n0"],"version":1}]}
## query agg limit 1
["Dept","CountId"]
[["eng",2]]
## session rules after agg debug/why (should not leak il_sdk_agg)
["message"]
[["No session data defined."]]

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed (3) ✅
  • ⚠️ packages/inputlayer-js/src/knowledge-graph.ts:609 - kg.debug() (line 609) and kg.why() (line 625) still call compileQuery() and ignore the new QueryPlan. query() was the only method moved to the plan. Two cases go wrong. (1) Aggregates: compileQuery now returns a two-line program (il_sdk_agg(...) &lt;- body followed by ?il_sdk_agg(...)). Prefixing it with .debug /.why only wraps the first line. The engine runs the second line as a separate query (handler.rs line loop, around line 2504), and that query fails because il_sdk_agg was never registered as a session rule. Example: kg.debug({select:[Employee.col(&#39;department&#39;).toAst(), count(Employee.col(&#39;id&#39;))], join:[Employee]}) returns an error instead of a plan. (2) Plain queries: why() returns the engine's raw columns, which are every variable in every atom, not the selected/labelled columns query() returns for the same options. OR splits in both methods still use only the first branch, but that was already the case. Neither aggregate debug/why nor why() is documented in js-sdk.mdx, and aggregate debug/why was already broken under the old ?H &lt;- body form, so this does not block the stated intent. It is a sibling path of the same compile-to-engine contract, though. What needs deciding is the remedy: how debug/why should handle a rule-plus-query program, and whether why() should project to the plan's outputs.

🔧 Fix applied.
2 warnings still open:

  • ⚠️ packages/inputlayer-js/src/knowledge-graph.ts:609 - kg.debug() (line 609) and kg.why() (line 625) still call compileQuery() and ignore the new QueryPlan. query() was the only method moved to the plan. Two cases go wrong. (1) Aggregates: compileQuery now returns a two-line program (il_sdk_agg(...) &lt;- body followed by ?il_sdk_agg(...)). Prefixing it with .debug /.why only wraps the first line. The engine runs the second line as a separate query (handler.rs line loop, around line 2504), and that query fails because il_sdk_agg was never registered as a session rule. Example: kg.debug({select:[Employee.col(&#39;department&#39;).toAst(), count(Employee.col(&#39;id&#39;))], join:[Employee]}) returns an error instead of a plan. (2) Plain queries: why() returns the engine's raw columns, which are every variable in every atom, not the selected/labelled columns query() returns for the same options. OR splits in both methods still use only the first branch, but that was already the case. Neither aggregate debug/why nor why() is documented in js-sdk.mdx, and aggregate debug/why was already broken under the old ?H &lt;- body form, so this does not block the stated intent. It is a sibling path of the same compile-to-engine contract, though. What needs deciding is the remedy: how debug/why should handle a rule-plus-query program, and whether why() should project to the plan's outputs.
  • ⚠️ packages/inputlayer-js/src/knowledge-graph.ts:626 - This defect is in code that review round 1's fix added (moving why() onto the QueryPlan). why() now passes the engine's .why columns through projectRows → resultColumnIndexes (compiler.ts:752). That function assumes the result columns follow ?query naming: named by variable, or schema-named and positional over the first atom. The .why path names columns differently. extract_query_schema/extract_head_variables (src/protocol/handler.rs:9152-9192) splits the raw text between the first '(' and the LAST ')', and labels any column beyond that col{i}. Two cases now throw Query result has no column for … where why() used to return rows. (1) A join that selects a column of the second relation. Example: ?employee(Id, …, Active), department(Department, Floor) gives the columns …, &#34;Active)&#34;, &#34;department(Department&#34;, &#34;Floor&#34;. byName is false, so the fallback uses goalVars.indexOf(&#39;Floor&#39;), which is -1. (2) A select of a computed or expression column. Example: ?employee(…), Expr = Salary * 2 names the extra column col5. byName is true, so columns.indexOf(&#39;Expr&#39;) is -1. A where with a function call, or a string containing ')', also garbles the names. The new live tests cover only a one-relation select of plain columns, so they pass with this bug. Fix: for why(), resolve columns by the known variable order of plan.explain (the engine's __query__ head lists the variables in order of first appearance) instead of trusting the engine's .why column names. Alternatively, fix extract_head_variables in the engine so .why names columns the way the query path does. Add live why() tests for a join that selects a second-relation column and for a computed column. Related sibling from the same round: the aggregate explain rule (compiler.ts compileAggPlan) has no orderBy or limit/offset. So kg.why({... count(...), limit: 1}) returns every group, while query() returns one.

🔧 Fix applied.
1 info still open:

  • ℹ️ packages/inputlayer-js/src/compiler.ts:442 - limitAtom emits nothing when offset is set without limit, so a single-program kg.query({ ..., offset: 2 }) ignores the offset. That was already the case before this change. The new client-side paging in pageOf (knowledge-graph.ts:927), which round 2 added for why() and which this change added for merged OR branches, does apply a lone offset. So the same options give different rows depending on whether the where has an OR, and kg.why() disagrees with kg.query(). Fix: pick one meaning for a lone offset. Either emit an offset-only pagination atom, if the engine supports one, or have pageOf ignore offset when limit is undefined, which matches the engine path.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A user runs each documented js-sdk.mdx kg.query() example (basic, filter, columns, joins, self-joins, computed, ordering/pagination, aggregations, topK, sessions, distance, temporal, derived relations… ✅ pass live js-test-live.log (make js-test-live: 25/25 passed)
Regression: the same documented queries on the pre-fix SDK are rejected by the engine with 'Query cannot contain a rule definition' ✅ pass live base-sdk-repro.log: 24 failed / 1 passed (only Raw IQL passes)
kg.debug() on an aggregate query returns a query plan instead of an error, and leaves no il_sdk_agg session rule behind ✅ pass live adhoc-transcript.txt 'debug aggregate' and '.session' -> 'No session data defined.'; js-test-live 'debug shows the plan of an aggregate query'
kg.why() returns the selected columns, including a second joined relation's column and a computed column, rather than the engine's raw .why columns ✅ pass live adhoc-transcript.txt 'why join second relation col' -> columns [Name, Floor]; js-test-live why tests
kg.why() on an aggregate with orderBy and limit 1 returns the same single group as kg.query() ✅ pass live adhoc-transcript.txt 'why agg limit 1' and 'query agg limit 1' both [[eng,2]]
Adversarial: offset without limit skips the first N ordered rows the same way in query(), why() and OR-merged queries ✅ pass live adhoc-transcript.txt lone-offset query and why both [[Cy,70],[Di,60]]; js-test-live offset test
Adversarial: OR where-clause merges both branches, and data containing parentheses/apostrophes does not break results ✅ pass live adhoc-transcript.txt 'OR dept=hr or salary>95' -> Di, Ann; filter returns "O'Neil (x)"
  • make js-test-live JS_LIVE_PORT=8197 (release inputlayer-server on a scratch data dir + vitest run tests/docs-queries.integration.test.ts, 25 live tests)
  • Base-commit reproduction: git archive 4adce1e3 packages/inputlayer-js, then the same live docs-queries suite run on that copy against a scratch server on port 8198
  • Ad-hoc vite-node adhoc.ts script using the new SDK against the scratch server: all rows, filter (with '(' and an apostrophe in the data), join, count/avg aggregate, orderBy+limit, lone offset in query() and why(), OR merge, debug of an aggregate, why() selecting a second-relation column, why()/query() aggregate with limit 1, then .session to confirm no il_sdk_agg rule leaked
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

jsam added 4 commits October 4, 2026 09:14
kg.query() compiled every query to `?Head <- body`, which the engine parser
rejects ("Query cannot contain a rule definition"), so every query example
in the JS SDK guide failed against a real server. The integration tests
used raw IQL, and CI never ran them.

- Plain queries compile to `?atom, body...`, as the Python SDK does. The
  engine returns every variable the query binds, so the SDK picks the
  selected columns out of the rows. ORDER BY becomes a sort annotation in
  the first atom (its relation is moved first), so the engine sorts before
  limit/offset. Latency matches the equivalent raw IQL.
- Aggregates compile to a rule and a query in one program; the rule lives
  only for that request. Each aggregate gets its own column (CountId,
  AvgSalary), topK expands to its passthrough and ordered columns, and an
  OR split is aggregated over the union of its branches.
- OR branches are merged client-side: union deduplicated on the atom
  variables, then ordered and paginated.
- Non-equality join conditions (e1.id != e2.id) are kept as filters
  instead of being dropped.
- New tests/docs-queries.integration.test.ts runs every query example in
  js-sdk.mdx against a live engine; `make js-test-live` builds and starts a
  server for it, and the full suite runs it in a new job.
- Fix two guide examples the engine could not run: topK per group needs
  the kept columns in `passthrough`, and the last-hour filter compares
  the timestamp with fn.timeSub() instead of passing fn.withinLast() as a
  condition.
@jsam
jsam merged commit 3920d54 into main Oct 4, 2026
17 checks passed
@jsam
jsam deleted the fm/il-fix-js-query branch October 4, 2026 09:39
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