Skip to content

fix(inputlayer-js): compile timestamps, count(), in()/notIn() and meta commands the engine accepts - #233

Merged
jsam merged 3 commits into
mainfrom
fm/sdk-p1-b
Oct 4, 2026
Merged

jsam merged 3 commits into
mainfrom
fm/sdk-p1-b

Conversation

@jsam

@jsam jsam commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Intent

So fix the SDKs by improving them. (Context: the captain asked to rework the SDKs so agents and agentic loops use InputLayer without writing Datalog, and to fix the SDK release blockers by improving the SDKs rather than patching. The agreed design, signed off by an independent challenger after three rounds, is the InputLayer SDK redesign report; this item is its phase-1 PR P1-B (JS).)

Substance of the referenced item P1-B (JavaScript SDK, packages/inputlayer-js), from that design:

  • timestamp columns: the engine rejects the timestamp schema type, so Date/Timestamp columns declare int and carry Unix milliseconds (rule R-TYPE).
  • count() without a column compiled to count<>, which the engine rejects; it must compile to count<V> over the first variable of the first atom (rule R-AGG).
  • in() (compileIn) emitted rel(..., X, ...), which is not IQL; col.in(Other.col) must emit a positive atom of the other relation with the variable in that column and _ elsewhere, and notIn the negated atom (rule R-IN).
  • Session listing sent .session list (rejected); the command is .session (and drops are .session drop <n|name>). Meta commands come from one shared table, with the same JSON fixture used by both SDKs, so a wrong spelling cannot exist in one SDK only (rule R-META: .session, .rule def, .rule drop, .subscribe, .unsubscribe, .why, .why_not).
  • The stale JS integration test at tests/integration.test.ts:840-850 expected pre-feat(storage): atomic multi-statement programs (#131) #208 partial-apply semantics for a program mixing writes with a command; its expectation becomes unsupported plus rollback, and that test file runs in CI in the js-sdk-live job.
    Each rule is pinned by unit tests on the compiled text and live tests against a real engine that run in CI. Other phase-1 items (Python compile path, connection cores, subscriptions, claims, README hero) are separate PRs and out of scope.

What Changed

  • Compiler. timestamp columns are now declared as int and store Unix milliseconds, and Date values are written as milliseconds. A count() with no column now compiles to count<V> over the first column of the first relation, where it used to emit count<>. col.in(Other.col) / notIn now compile to a positive or negated atom of the target relation, with the variable in the target column and _ in every other position. Targets that aren't real relation columns are rejected.
  • Meta commands. All meta commands now come from one table in the new src/meta.ts, which also has parsers for the .session, .rule list and .rule def replies. The table is pinned by a shared fixture, packages/conformance/meta-commands.json. This fixes the wrong spellings .session list, .session remove and .rule show. session.dropRule(name, index) now works out the clause's position across all session rules and sends .session drop <n>.
  • Tests and docs. Added unit tests for the compiled text and for the meta table and fixture. Added live integration tests, including one that sends every fixture command to the engine. The stale mixed-program test now expects unsupported and a rollback. make js-test-live now also runs tests/integration.test.ts. The JS SDK guide (and the GUI docs bundle) are updated for the timestamp type and session rule drops.

Risk Assessment

⚠️ Medium: The change follows the R-TYPE/R-AGG/R-IN/R-META rules and has unit and live tests, but it rewrites shared compiler paths (count rewrite, in() atoms, compiling rule conditions before the atoms) and the rule/session reply parsing. It also widens the public API and leaves one way of building in() targets that throws.

Testing

I built the SDK from HEAD and started a disposable real inputlayer-server (the prebuilt release binary from main, copied to a scratch dir with a fresh data dir). Against it I ran the JS live integration suites (81 tests, all passing, including the new timestamp, count(), in()/notIn(), meta-table and mixed-program tests). I also ran a wire-logging driver for 10 end-user scenarios, all passing. The wire log shows count&lt;Id&gt;, mgr(_, Id) / !mgr(_, Id) atoms, .session / .session drop &lt;n|name&gt; / .rule def, a misspelled in() target raising a clear error, and a mixed program rejected as unsupported at index 1 with no writes applied. The server and scratch dir were torn down afterwards.

  • Live validation: ✅ go - 6 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Defining a relation with a timestamp column succeeds (declared as int); Date/Timestamp values are stored as Unix ms and can be filtered ✅ pass live driver-live.log R-TYPE (wire +event(id: int, occurredAt: int), filter returns [[2,1704153600000]]); vitest-live.log 'defines a timestamp column and stores Unix milliseconds'
count() without a column returns the row count via kg.query(), grouped, and in a rule head ✅ pass live driver-live.log R-AGG (compiles to count<Id>; 3 total, [[eng,2],[hr,1]]); vitest-live.log count() tests
in()/notIn() filter by membership in another relation, including a target from a from(A,B).where callback and a conditional delete ✅ pass live driver-live.log R-IN scenarios (notIn -> [1,3], in -> [2], callback -> [2], delete leaves [1,3]); vitest-live.log 'filters by membership in another relation with in() and notIn()'
Misspelled in() target column raises a clear error instead of compiling an all-_ atom ✅ pass live driver-live.log: "column 'empID' does not exist on relation 'mgr'. Available: since, empId"
session listRules/dropRule(name, idx)/dropRule(name)/clear and persistent listRules/ruleDefinition/dropRule send engine-accepted meta commands; the old .session list is rejected ✅ pass live driver-live.log R-META wire lines; vitest-live.log 'the engine accepts every command in the shared table' and 'rejects the spellings the table must never produce'
A program mixing writes with a command is rejected as unsupported and none of its writes apply ✅ pass live driver-live.log (StatementFailedError [[1,'unsupported']], 0 rows for 300 and 301); vitest-live.log 'a program mixing writes with a command is rejected before any statement runs'
Evidence: Live vitest run (integration + docs-queries) against real engine

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

 ✓ tests/integration.test.ts > Integration: Connection > connects and authenticates 1ms
 ✓ tests/integration.test.ts > Integration: Connection > lists knowledge graphs 1ms
 ✓ tests/integration.test.ts > Integration: Schema > compileSchema produces valid IQL 0ms
 ✓ tests/integration.test.ts > Integration: Schema > defines and uses relations via SDK 53ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Basic Queries: all rows 3ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Basic Queries: with a filter 1ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Selecting Specific Columns 1ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Joins 2ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Self-Joins 2ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Computed Columns 1ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Ordering and Pagination 2ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Aggregations: group by department with stats 2ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Aggregations: topK per group 2ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Working with Results 2ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Query Plans 1ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > debug shows the plan of an aggregate query 1ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > why returns the selected columns with a proof per row 1ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > why explains an aggregate query 1ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > why returns a column of the second joined relation 2ms
 ✓ tests/integration.test.ts > Integration: Schema > define is idempotent 1ms
 ✓ tests/integration.test.ts > Integration: Schema > defines a timestamp column and stores Unix milliseconds 15ms
 ✓ tests/integration.test.ts > Integration: Insert & Query > compileInsert produces valid IQL 0ms
 ✓ tests/integration.test.ts > Integration: Insert & Query > inserts and queries batch facts 18ms
 ✓ tests/integration.test.ts > Integration: Insert & Query > inserts a single fact 3ms
 ✓ tests/integration.test.ts > Integration: Insert & Query > queries with filter via raw IQL 22ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > why returns a computed column 1ms
 ✓ 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 5ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > why orders and paginates like query 2ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > why orders and limits an aggregate query like query 2ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Raw IQL 1ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Derived Relations: querying a recursive rule 32ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Sessions: session facts mix with persistent data 2ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Distance Functions 1ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > Temporal Functions 3ms
 ✓ tests/docs-queries.integration.test.ts > js-sdk.mdx query examples > OR conditions merge their branches 2ms
 ✓ tests/integration.test.ts > Integration: Insert & Query > queries with ordering and limit 1ms
 ✓ tests/integration.test.ts > Integration: Joins > joins two relations via raw IQL 2ms
 ✓ tests/integration.test.ts > Integration: Aggregations > count aggregation via rule 8ms
 ✓ tests/integration.test.ts > Integration: Aggregations > sum aggregation via rule 9ms
 ✓ tests/integration.test.ts > Integration: Aggregations > group-by count via rule 8ms
 ✓ tests/integration.test.ts > Integration: Aggregations > count() without a column counts every row through kg.query() 4ms
 ✓ tests/integration.test.ts > Integration: Aggregations > count() without a column in a rule head 9ms
 ✓ tests/integration.test.ts > Integration: Aggregations > min/max aggregation via rule 24ms
 ✓ tests/integration.test.ts > Integration: Rules > compileRule produces valid IQL 0ms
 ✓ tests/integration.test.ts > Integration: Rules > defines and queries a recursive rule 29ms
 ✓ tests/integration.test.ts > Integration: Rules > lists rules 0ms
 ✓ tests/integration.test.ts > Integration: Rules > listRules() and ruleDefinition() read the engine replies 1ms
 ✓ tests/integration.test.ts > Integration: Rules > drops a rule 15ms
 ✓ tests/integration.test.ts > Integration: Delete > compileDelete produces valid IQL 0ms
 ✓ tests/integration.test.ts > Integration: Delete > deletes a specific fact via SDK 17ms
 ✓ tests/integration.test.ts > Integration: Delete > deletes by condition via SDK 13ms
 ✓ tests/integration.test.ts > Integration: Delete > filters by membership in another relation with in() and notIn() 43ms
 ✓ tests/integration.test.ts > Integration: Sessions > inserts ephemeral session facts 16ms
 ✓ tests/integration.test.ts > Integration: Sessions > lists and drops session rules 4ms
 ✓ tests/integration.test.ts > Integration: Sessions > clears session data 1ms
 ✓ tests/integration.test.ts > Integration: Vector Search > top-k query rejection is typed 1ms
 ✓ tests/integration.test.ts > Integration: Vector Search > radius query rejection is typed 1ms
 ✓ tests/integration.test.ts > Integration: Indexes > creates and drops an HNSW index 5ms
 ✓ tests/integration.test.ts > Integration: ResultSet > result has correct row count 1ms
 ✓ tests/integration.test.ts > Integration: ResultSet > first() returns first row 1ms
 ✓ tests/integration.test.ts > Integration: ResultSet > toDicts() returns array of objects 1ms
 ✓ tests/integration.test.ts > Integration: ResultSet > toTuples() returns array of arrays 1ms
 ✓ tests/integration.test.ts > Integration: ResultSet > is iterable 1ms
 ✓ tests/integration.test.ts > Integration: ResultSet > has execution metadata 1ms
 ✓ tests/integration.test.ts > Integration: Raw IQL > executes raw IQL statements 18ms
 ✓ tests/integration.test.ts > Integration: Raw IQL > server status 0ms
 ✓ tests/integration.test.ts > Integration: Multi-KG > creates and lists a knowledge graph 16ms
 ✓ tests/integration.test.ts > Integration: Debug > debugs a query plan via raw IQL 1ms
 ✓ tests/integration.test.ts > Integration: Drop Relation > drops a relation 21ms
 ✓ tests/integration.test.ts > Integration: ACL > lists ACL entries 1ms
 ✓ tests/integration.test.ts > Integration: Server Operations > gets server status 1ms
 ✓ tests/integration.test.ts > Integration: Server Operations > triggers compaction without error 56ms
 ✓ tests/integration.test.ts > Integration: Engine failures > a failed insert rejects 1ms
 ✓ tests/integration.test.ts > Integration: Engine failures > insert counts the facts the engine stored 6ms
 ✓ tests/integration.test.ts > Integration: Engine failures > a partial failure names each failed statement 3ms
 ✓ tests/integration.test.ts > Integration: Engine failures > a program mixing writes with a command is rejected before any statement runs 2ms
 ✓ tests/integration.test.ts > Integration: Engine failures > a partial failure survives a chunked result 139ms
 ✓ tests/integration.test.ts > Integration: Engine failures > load is refused over the WebSocket with a typed error 1ms
 ✓ tests/integration.test.ts > Integration: Engine failures > dropping the current knowledge graph rejects without switching it 5ms
 ✓ tests/integration.test.ts > Integration: Meta commands > the engine accepts every command in the shared table 62ms
 ✓ tests/integration.test.ts > Integration: Meta commands > the engine rejects the spellings the table must never produce 1ms

 Test Files  2 passed (2)
      Tests  81 passed (81)
   Start at  11:38:19
   Duration  2.05s (transform 239ms, setup 0ms, collect 474ms, tests 1.85s, environment 0ms, prepare 151ms)
Evidence: End-user driver transcript with wire statements

=== R-TYPE: timestamp column declared int, Date/Timestamp stored as Unix ms
   >> wire: ".kg use p1b_driver_1791113904367"
   >> wire: ".kg create p1b_driver_1791113904367"
   >> wire: ".kg use p1b_driver_1791113904367"
   >> wire: "+event(id: int, occurredAt: int)"
   >> wire: "+event[(1, 1704067200000), (2, 1704153600000)]"
   >> wire: ".rel event"
   describe: {"name":"event","columns":[{"name":"1","type":"1704067200000"},{"name":"2","type":"1704153600000"}],"rowCount":0,"sample":[]}
   >> wire: "?event(Id, OccurredAt), OccurredAt > 1704100000000"
   rows after ts filter: got [[2,1704153600000]]
PASS: R-TYPE: timestamp column declared int, Date/Timestamp stored as Unix ms
   >> wire: "+emp(id: int, dept: string)"
   >> wire: "+mgr(since: int, empId: int)"
   >> wire: "+emp[(1, \"eng\"), (2, \"eng\"), (3, \"hr\")]"
   >> wire: "+mgr(2020, 2)"

=== R-AGG: count() without a column compiles to count<V> and engine counts rows
   compiled: "il_sdk_agg(count<Id>) <- emp(Id, Dept)\n?il_sdk_agg(Count)"
   >> wire: "il_sdk_agg(count<Id>) <- emp(Id, Dept)\n?il_sdk_agg(Count)"
   total count: got 3
   >> wire: "il_sdk_agg(Dept, count<Id>) <- emp(Id, Dept)\n?il_sdk_agg(Dept:asc, Count)"
   count by dept: got [["eng",2],["hr",1]]
   >> wire: "+dept_n(Dept, count<Id>) <- emp(Id, Dept)"
   >> wire: "?dept_n(D, N)"
   rule-head count: got [["eng",2],["hr",1]]
PASS: R-AGG: count() without a column compiles to count<V> and engine counts rows

=== R-IN: in()/notIn() emit a positive/negated atom of the other relation
   compiled notIn rule: +non_mgr(Id) <- emp(Id, _), !mgr(_, Id)
   >> wire: "+non_mgr(Id) <- emp(Id, _), !mgr(_, Id)"
   >> wire: "?non_mgr(X)"
   notIn result: got [1,3]
   compiled in rule: +is_mgr(Id) <- emp(Id, _), mgr(_, Id)
   >> wire: "+is_mgr(Id) <- emp(Id, _), mgr(_, Id)"
   >> wire: "?is_mgr(X)"
   in result: got [2]
PASS: R-IN: in()/notIn() emit a positive/negated atom of the other relation

=== R-IN: in() with target from a from(A,B).where((a,b)=>...) callback
   compiled: +mgr2(Id) <- emp(Id, _), mgr(_, _), mgr(_, Id)
   >> wire: "+mgr2(Id) <- emp(Id, _), mgr(_, _), mgr(_, Id)"
   >> wire: "?mgr2(X)"
   in via callback: got [2]
PASS: R-IN: in() with target from a from(A,B).where((a,b)=>...) callback

=== R-IN adversarial: misspelled target column raises instead of compiling all-_ atom
   error: in()/notIn(): column 'empID' does not exist on relation 'mgr'. Available: since, empId
PASS: R-IN adversarial: misspelled target column raises instead of compiling all-_ atom

=== R-IN: conditional delete with in() removes members of the other relation
   >> wire: "-emp(X0, X1) <- emp(X0, X1), mgr(_, X0)"
   >> wire: "?emp(Id, D)"
   after delete: got [1,3]
PASS: R-IN: conditional delete with in() removes members of the other relation

=== R-META: session listRules / dropRule(name, idx) / dropRule(name) / clear
   >> wire: "hop(Id, Id) <- emp(Id, _)"
   >> wire: "hop(Id, Dept) <- emp(Id, Dept)"
   >> wire: ".session"
   listRules: ["hop(Id, Id) <- emp(Id, _)","hop(Id, Dept) <- emp(Id, Dept)"]
   session rule count: got 2
   >> wire: ".session"
   >> wire: ".session drop 2"
   >> wire: ".session"
   after dropRule(hop, 2): got ["hop(Id, Id) <- emp(Id, _)"]
   >> wire: ".session"
   out-of-range: RangeError Session rule 'hop' has 1 clause(s); index 5 is out of range
   >> wire: ".session drop hop"
   >> wire: ".session"
   after dropRule(hop): got []
   >> wire: ".session clear"
PASS: R-META: session listRules / dropRule(name, idx) / dropRule(name) / clear

=== R-META: persistent listRules / ruleDefinition / dropRule
   >> wire: ".rule list"
   listRules: [{"name":"dept_n","clauseCount":1},{"name":"is_mgr","clauseCount":1},{"name":"mgr2","clauseCount":1},{"name":"non_mgr","clauseCount":1}]
   >> wire: ".rule def non_mgr"
   >> wire: ".rule def non_mgr"
   ruleDefinition stable: got ["non_mgr(Id) <- emp(Id, _), !mgr(_, Id)"]
   >> wire: ".rule def non_mgr"
   ruleDefinition(non_mgr): ["non_mgr(Id) <- emp(Id, _), !mgr(_, Id)"]
   >> wire: ".rule drop non_mgr"
   >> wire: ".rule list"
   after drop: ["dept_n","is_mgr","mgr2"]
PASS: R-META: persistent listRules / ruleDefinition / dropRule

=== Mixed write+command program: unsupported, rolled back
   >> wire: "+mgr[(1, 300)]\n.rel drop no_such_rel\n+mgr[(1, 301)]"
   error: StatementFailedError [[1,"unsupported"]]
   errors: got [[1,"unsupported"]]
   >> wire: "?mgr(S, 300)"
   no write applied (300): got 0
   >> wire: "?mgr(S, 301)"
   no write applied (301): got 0
PASS: Mixed write+command program: unsupported, rolled back

=== R-META adversarial: engine rejects the old `.session list` spelling
   >> wire: ".session list"
   error: QueryError Program has 1 parse error(s)
PASS: R-META adversarial: engine rejects the old `.session list` spelling
   >> wire: ".kg drop p1b_driver_1791113904367"

SUMMARY
PASS  R-TYPE: timestamp column declared int, Date/Timestamp stored as Unix ms
PASS  R-AGG: count() without a column compiles to count<V> and engine counts rows
PASS  R-IN: in()/notIn() emit a positive/negated atom of the other relation
PASS  R-IN: in() with target from a from(A,B).where((a,b)=>...) callback
PASS  R-IN adversarial: misspelled target column raises instead of compiling all-_ atom
PASS  R-IN: conditional delete with in() removes members of the other relation
PASS  R-META: session listRules / dropRule(name, idx) / dropRule(name) / clear
PASS  R-META: persistent listRules / ruleDefinition / dropRule
PASS  Mixed write+command program: unsupported, rolled back
PASS  R-META adversarial: engine rejects the old `.session list` spelling
Evidence: Driver script
// End-user driver: uses the built SDK bundle against a live inputlayer-server,
// logging every statement the SDK sends on the wire and every result.
import {
  InputLayer, Connection, relation, from, count, Timestamp, compileQuery, compileRule, QueryError, StatementFailedError,
} from './sdk-dist/index.mjs';

const URL_ = process.env.IL_URL;
const origExec = Connection.prototype.execute;
Connection.prototype.execute = async function (text, ...rest) {
  console.log(`   >> wire: ${JSON.stringify(text)}`);
  return origExec.call(this, text, ...rest);
};

const results = [];
async function scenario(name, fn) {
  console.log(`\n=== ${name}`);
  try { await fn(); console.log(`PASS: ${name}`); results.push([name, 'PASS']); }
  catch (e) { console.log(`FAIL: ${name}: ${e?.stack ?? e}`); results.push([name, 'FAIL']); }
}
function eq(a, b, msg) {
  const ja = JSON.stringify(a), jb = JSON.stringify(b);
  console.log(`   ${msg}: got ${ja}`);
  if (ja !== jb) throw new Error(`${msg}: expected ${jb}, got ${ja}`);
}

const client = new InputLayer({ url: URL_, username: 'admin', password: 'admin' });
await client.connect();
const kg = client.knowledgeGraph('p1b_driver_' + Date.now());

await scenario('R-TYPE: timestamp column declared int, Date/Timestamp stored as Unix ms', async () => {
  const Event = relation('Event', { id: 'int', occurredAt: 'timestamp' });
  await kg.define(Event);
  await kg.insert(Event, [
    { id: 1, occurredAt: new Timestamp(1704067200000) },
    { id: 2, occurredAt: new Date(1704153600000) },
  ]);
  const d = await kg.describe(Event);
  console.log('   describe:', JSON.stringify(d));
  const r = await kg.query({ select: [Event.col('id').toAst(), Event.col('occurredAt').toAst()], join: [Event], where: Event.col('occurredAt').gt(1704100000000) });
  eq(r.rows, [[2, 1704153600000]], 'rows after ts filter');
});

const Emp = relation('Emp', { id: 'int', dept: 'string' });
const Mgr = relation('Mgr', { since: 'int', empId: 'int' });
await kg.define(Emp, Mgr);
await kg.insert(Emp, [{ id: 1, dept: 'eng' }, { id: 2, dept: 'eng' }, { id: 3, dept: 'hr' }]);
await kg.insert(Mgr, [{ since: 2020, empId: 2 }]);

await scenario('R-AGG: count() without a column compiles to count<V> and engine counts rows', async () => {
  console.log('   compiled:', JSON.stringify(compileQuery({ select: [count()], join: [Emp] })));
  const total = await kg.query({ select: [count()], join: [Emp] });
  eq(total.scalar(), 3, 'total count');
  const by = await kg.query({ select: [Emp.col('dept').toAst(), count()], join: [Emp], orderBy: Emp.col('dept').asc() });
  eq(by.rows, [['eng', 2], ['hr', 1]], 'count by dept');
  await kg.defineRules('dept_n', ['dept', 'n'], [from(Emp).select({ dept: Emp.col('dept'), n: count() })]);
  const r = await kg.execute('?dept_n(D, N)');
  eq(r.rows.sort(), [['eng', 2], ['hr', 1]], 'rule-head count');
});

await scenario('R-IN: in()/notIn() emit a positive/negated atom of the other relation', async () => {
  const notInRule = from(Emp).where((e) => e.col('id').notIn(Mgr.col('empId'))).select({ id: Emp.col('id') });
  console.log('   compiled notIn rule:', compileRule('non_mgr', ['id'], notInRule));
  await kg.defineRules('non_mgr', ['id'], [notInRule]);
  eq((await kg.execute('?non_mgr(X)')).rows.map((r) => r[0]).sort(), [1, 3], 'notIn result');
  const inRule = from(Emp).where((e) => e.col('id').in(Mgr.col('empId'))).select({ id: Emp.col('id') });
  console.log('   compiled in rule:', compileRule('is_mgr', ['id'], inRule));
  await kg.defineRules('is_mgr', ['id'], [inRule]);
  eq((await kg.execute('?is_mgr(X)')).rows.map((r) => r[0]), [2], 'in result');
});

await scenario('R-IN: in() with target from a from(A,B).where((a,b)=>...) callback', async () => {
  const rule = from(Emp, Mgr).where((e, m) => e.col('id').in(m.col('empId'))).select({ id: Emp.col('id') });
  console.log('   compiled:', compileRule('mgr2', ['id'], rule));
  await kg.defineRules('mgr2', ['id'], [rule]);
  eq((await kg.execute('?mgr2(X)')).rows.map((r) => r[0]), [2], 'in via callback');
});

await scenario('R-IN adversarial: misspelled target column raises instead of compiling all-_ atom', async () => {
  let err;
  try { compileRule('bad', ['id'], from(Emp, Mgr).where((e, m) => e.col('id').in(m.col('empID'))).select({ id: Emp.col('id') })); }
  catch (e) { err = e; }
  console.log('   error:', err?.message);
  if (!err || !/does not exist/.test(err.message)) throw new Error('expected a clear error');
});

await scenario('R-IN: conditional delete with in() removes members of the other relation', async () => {
  await kg.delete(Emp, Emp.col('id').in(Mgr.col('empId')));
  eq((await kg.execute('?emp(Id, D)')).rows.map((r) => r[0]).sort(), [1, 3], 'after delete');
});

await scenario('R-META: session listRules / dropRule(name, idx) / dropRule(name) / clear', async () => {
  await kg.session.defineRules('hop', ['a', 'b'], [
    from(Emp).select({ a: Emp.col('id'), b: Emp.col('id') }),
    from(Emp).select({ a: Emp.col('id'), b: Emp.col('dept') }),
  ]);
  const rules = await kg.session.listRules();
  console.log('   listRules:', JSON.stringify(rules));
  eq(rules.length, 2, 'session rule count');
  await kg.session.dropRule('hop', 2);
  eq(await kg.session.listRules(), rules.slice(0, 1), 'after dropRule(hop, 2)');
  let rangeErr; try { await kg.session.dropRule('hop', 5); } catch (e) { rangeErr = e; }
  console.log('   out-of-range:', rangeErr?.constructor?.name, rangeErr?.message);
  if (!(rangeErr instanceof RangeError)) throw new Error('expected RangeError');
  await kg.session.dropRule('hop');
  eq(await kg.session.listRules(), [], 'after dropRule(hop)');
  await kg.session.clear();
});

await scenario('R-META: persistent listRules / ruleDefinition / dropRule', async () => {
  console.log('   listRules:', JSON.stringify(await kg.listRules()));
  eq(await kg.ruleDefinition('non_mgr'), (await kg.ruleDefinition('non_mgr')), 'ruleDefinition stable');
  console.log('   ruleDefinition(non_mgr):', JSON.stringify(await kg.ruleDefinition('non_mgr')));
  await kg.dropRule('non_mgr');
  const names = (await kg.listRules()).map((r) => r.name);
  if (names.includes('non_mgr')) throw new Error('non_mgr still listed');
  console.log('   after drop:', JSON.stringify(names));
});

await scenario('Mixed write+command program: unsupported, rolled back', async () => {
  let err; try { await kg.execute('+mgr[(1, 300)]\n.rel drop no_such_rel\n+mgr[(1, 301)]'); } catch (e) { err = e; }
  console.log('   error:', err?.constructor?.name, JSON.stringify(err?.errors?.map((e) => [e.index, e.code])));
  if (!(err instanceof StatementFailedError)) throw new Error('expected StatementFailedError');
  eq(err.errors.map((e) => [e.index, e.code]), [[1, 'unsupported']], 'errors');
  eq((await kg.execute('?mgr(S, 300)')).rows.length, 0, 'no write applied (300)');
  eq((await kg.execute('?mgr(S, 301)')).rows.length, 0, 'no write applied (301)');
});

await scenario('R-META adversarial: engine rejects the old `.session list` spelling', async () => {
  let err; try { await kg.execute('.session list'); } catch (e) { err = e; }
  console.log('   error:', err?.constructor?.name, err?.message);
  if (!(err instanceof QueryError)) throw new Error('expected rejection');
});

try { await client.dropKnowledgeGraph(kg.name ?? kg._name); } catch {}
await client.close();
console.log('\nSUMMARY');
for (const [n, r] of results) console.log(`${r}  ${n}`);
process.exit(results.some(([, r]) => r !== 'PASS') ? 1 : 0);
Evidence: Key compiled/wire output
+event(id: int, occurredAt: int)
il_sdk_agg(count<Id>) <- emp(Id, Dept)
+non_mgr(Id) <- emp(Id, _), !mgr(_, Id)
+is_mgr(Id) <- emp(Id, _), mgr(_, Id)
error: in()/notIn(): column 'empID' does not exist on relation 'mgr'. Available: since, empId
wire: .session / .session drop 2 / .session drop hop / .session clear / .rule def non_mgr / .rule drop non_mgr
mixed program: StatementFailedError [[1,"unsupported"]], 0 rows for both writes
- Outcome: ⚠️ 1 info across 2 runs (3m13s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 3 issues (2 warnings, 1 info)
  • ⚠️ packages/inputlayer-js/src/derived.ts:90 - in()/notIn() throws when its target comes from a from(...).where((a, b) =&gt; ...) callback. The callback's RelationProxy.col() (proxy.ts:205) builds a ColumnProxy without relationColumns, so targetColumns stays undefined and compileIn (compiler.ts:216) throws 'needs a column taken from a relation definition'. Example: from(Employee, Manager).where((e, m) =&gt; e.col(&#39;id&#39;).in(m.col(&#39;employeeId&#39;))). Of the three ways to get a column, RelationDef.col (relation.ts:53) and RelationRef.col (proxy.ts:226) pass the column list, but this one doesn't. Fix: give RelationProxy the column list (derived.ts:90 has it as r.def.columns) and pass it through in RelationProxy.col.
  • ⚠️ packages/inputlayer-js/src/index.ts:319 - This change adds to the SDK's public API: meta, sessionRules, ruleList, ruleClauses and the MetaCommandName type are now exported from the package entry point. The intent (R-META) asks for one internal table pinned by the shared fixture. It does not ask to make the raw command builders and reply parsers public API, which then has to stay stable. The only consumer is integration.test.ts, which could import from '../src/meta' instead. Recommendation: remove the public export unless publishing these is a deliberate product decision.
  • ℹ️ packages/inputlayer-js/src/meta.ts:4 - Some comments describe the Python SDK as already using this fixture, which it doesn't yet. meta.ts:4 says 'The Python SDK keeps the same table'. integration.test.ts:991 says the fixture is 'The fixture the Python SDK renders its command table against too'. The fixture description in packages/conformance/meta-commands.json says the same. At this commit packages/inputlayer-py has no meta table and doesn't read the fixture; that work is a separate phase-1 PR. Reword these comments as planned work (or as applying only to the JS SDK) until the Python PR lands.

🔧 Fix applied.
3 issues (2 warnings, 1 info) still open:

  • ⚠️ packages/inputlayer-js/src/index.ts:319 - This change adds to the SDK's public API: meta, sessionRules, ruleList, ruleClauses and the MetaCommandName type are now exported from the package entry point. The intent (R-META) asks for one internal table pinned by the shared fixture. It does not ask to make the raw command builders and reply parsers public API, which then has to stay stable. The only consumer is integration.test.ts, which could import from '../src/meta' instead. Recommendation: remove the public export unless publishing these is a deliberate product decision.
  • ⚠️ packages/inputlayer-js/src/compiler.ts:244 - compileIn puts _ in every column whose name doesn't equal target.name and never checks that target.name is one of the target's columns. If the target column name is misspelled, it compiles to an all-_ atom such as manager(_, _). in() then just checks that the relation has at least one row, and notIn() excludes everything once it does. Either way the result set is wrong and nothing raises an error. RelationDef.col (relation.ts:47) validates the name. Columns from the other two sources do not: RelationProxy.col (proxy.ts:207), whose column list the round-1 fix added, and RelationRef.col (proxy.ts:227). Example: from(Employee, Manager).where((e, m) =&gt; e.col(&#39;id&#39;).in(m.col(&#39;employeId&#39;))) compiles to manager(_, _). Fix: in compileIn, throw when !expr.targetColumns.includes(target.name). That one check covers all three column sources.
  • ℹ️ packages/inputlayer-js/src/index.ts:102 - The round-1 fix removed the meta exports but left a // Meta commands section header with nothing under it in the public entry point. Remove the header.

🔧 Fix applied.
3 issues (2 warnings, 1 info) still open:

  • ⚠️ packages/inputlayer-js/src/index.ts:319 - This change adds to the SDK's public API: meta, sessionRules, ruleList, ruleClauses and the MetaCommandName type are now exported from the package entry point. The intent (R-META) asks for one internal table pinned by the shared fixture. It does not ask to make the raw command builders and reply parsers public API, which then has to stay stable. The only consumer is integration.test.ts, which could import from '../src/meta' instead. Recommendation: remove the public export unless publishing these is a deliberate product decision.
  • ⚠️ packages/inputlayer-js/src/compiler.ts:244 - compileIn puts _ in every column whose name doesn't equal target.name and never checks that target.name is one of the target's columns. If the target column name is misspelled, it compiles to an all-_ atom such as manager(_, _). in() then just checks that the relation has at least one row, and notIn() excludes everything once it does. Either way the result set is wrong and nothing raises an error. RelationDef.col (relation.ts:47) validates the name. Columns from the other two sources do not: RelationProxy.col (proxy.ts:207), whose column list the round-1 fix added, and RelationRef.col (proxy.ts:227). Example: from(Employee, Manager).where((e, m) =&gt; e.col(&#39;id&#39;).in(m.col(&#39;employeId&#39;))) compiles to manager(_, _). Fix: in compileIn, throw when !expr.targetColumns.includes(target.name). That one check covers all three column sources.
  • ℹ️ packages/inputlayer-js/src/index.ts:102 - The round-1 fix removed the meta exports but left a // Meta commands section header with nothing under it in the public entry point. Remove the header.
⚠️ **Test** - 1 info
  • ⚠️ Live validation did not finish: I had to return a result before the release build of inputlayer-server (cargo build --release --bin inputlayer-server, started in this run) completed. Nothing has been confirmed against a real engine yet. To get it, re-run this step, or run make js-test-live plus the driver at ~/.no-mistakes/evidence/01M43ARJ94W366ZYWX6PEVYDQP/driver.mjs against the built server.
  • ⚠️ live validation verdict: inconclusive (0 of 6 scenarios were driven live against the product); untested: Defining a relation with a timestamp column succeeds; Timestamp/Date values are stored as Unix ms and can be filtered, count() without a column returns 3 for 3 rows via kg.query() and in a rule head, in()/notIn() filter by membership in another relation, including targets from a from(A,B).where callback and a conditional delete, Misspelled in() target column raises a clear error instead of compiling an all-_ atom, session.listRules/dropRule(name, idx)/dropRule(name)/clear and persistent listRules/ruleDefinition use the engine-accepted meta commands, A program mixing writes with a command is rejected as unsupported and none of its writes apply
  • Live validation: ⚠️ inconclusive - 0 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Defining a relation with a timestamp column succeeds; Timestamp/Date values are stored as Unix ms and can be filtered ⏸️ untested no The engine binary was still being built (cargo release build) when this step had to return, so no live server existed to run it against.
count() without a column returns 3 for 3 rows via kg.query() and in a rule head ⏸️ untested no The engine release build had not finished before the step had to return.
in()/notIn() filter by membership in another relation, including targets from a from(A,B).where callback and a conditional delete ⏸️ untested no The engine release build had not finished before the step had to return.
Misspelled in() target column raises a clear error instead of compiling an all-_ atom ⏸️ untested no The driver was not run because the step returned before the build finished. Only unit tests ran, and those are not live.
session.listRules/dropRule(name, idx)/dropRule(name)/clear and persistent listRules/ruleDefinition use the engine-accepted meta commands ⏸️ untested no The engine release build had not finished before the step had to return.
A program mixing writes with a command is rejected as unsupported and none of its writes apply ⏸️ untested no The engine release build had not finished before the step had to return.
  • npx vitest run tests/compiler.test.ts tests/meta.test.ts (44 passed)
  • cargo build --release --bin inputlayer-server (started; still compiling when the step returned)
  • Built the SDK bundle with npx tsup src/index.ts --format esm --out-dir &lt;evidence&gt;/sdk-dist and wrote an end-user driver script (driver.mjs) that logs the wire text for each scenario; not yet run against a live server

🔧 No changes applied.
1 info still open:

  • ℹ️ packages/inputlayer-js/src/knowledge-graph.ts:235 - This branch didn't cause this: during the live run, kg.describe() treated the data rows from the .rel &lt;name&gt; reply as columns (columns [{name:'1',type:'1704067200000'},...]). The parser was there before this change and this diff doesn't touch it. It's outside P1-B and is noted only for follow-up.
  • Live validation: ✅ go - 6 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Defining a relation with a timestamp column succeeds (declared as int); Date/Timestamp values are stored as Unix ms and can be filtered ✅ pass live driver-live.log R-TYPE (wire +event(id: int, occurredAt: int), filter returns [[2,1704153600000]]); vitest-live.log 'defines a timestamp column and stores Unix milliseconds'
count() without a column returns the row count via kg.query(), grouped, and in a rule head ✅ pass live driver-live.log R-AGG (compiles to count<Id>; 3 total, [[eng,2],[hr,1]]); vitest-live.log count() tests
in()/notIn() filter by membership in another relation, including a target from a from(A,B).where callback and a conditional delete ✅ pass live driver-live.log R-IN scenarios (notIn -> [1,3], in -> [2], callback -> [2], delete leaves [1,3]); vitest-live.log 'filters by membership in another relation with in() and notIn()'
Misspelled in() target column raises a clear error instead of compiling an all-_ atom ✅ pass live driver-live.log: "column 'empID' does not exist on relation 'mgr'. Available: since, empId"
session listRules/dropRule(name, idx)/dropRule(name)/clear and persistent listRules/ruleDefinition/dropRule send engine-accepted meta commands; the old .session list is rejected ✅ pass live driver-live.log R-META wire lines; vitest-live.log 'the engine accepts every command in the shared table' and 'rejects the spellings the table must never produce'
A program mixing writes with a command is rejected as unsupported and none of its writes apply ✅ pass live driver-live.log (StatementFailedError [[1,'unsupported']], 0 rows for 300 and 301); vitest-live.log 'a program mixing writes with a command is rejected before any statement runs'
  • npx tsup src/index.ts --format esm --out-dir &lt;evidence&gt;/sdk-dist (SDK bundle built from HEAD 1a931c46)
  • Started a scratch copy of the release inputlayer-server built from origin/main 23712b83: INPUTLAYER_ADMIN_PASSWORD=admin inputlayer-server --host 127.0.0.1 --port 46005 --data-dir &lt;fresh tmp dir&gt;
  • INPUTLAYER_TEST_SERVER=ws://127.0.0.1:46005/ws INPUTLAYER_TEST_USER=admin INPUTLAYER_TEST_PASSWORD=admin npx vitest run tests/integration.test.ts tests/docs-queries.integration.test.ts --reporter=verbose (81/81 passed live)
  • IL_URL=ws://127.0.0.1:46005/ws node driver.mjs: an end-user driver that uses the built SDK, logs every statement sent on the wire, and checks timestamp, count(), in()/notIn() (including where-callback targets and a misspelled column), conditional delete, session/persistent meta commands, mixed-program rollback, and rejection of .session list (10/10 PASS)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

jsam added 3 commits October 4, 2026 11:26
… commands the engine accepts

Phase-1 item P1-B of the SDK redesign: the JS SDK sent several forms the
engine rejects or misreads.

- A `timestamp` column is declared as `int` holding Unix milliseconds
  (the engine's schema parser rejects `timestamp`); `Timestamp` and `Date`
  values are written as milliseconds.
- `count()` with no column compiled to `count<>`, which the engine rejects.
  It now counts the first variable of the first atom (`count<Id>`), in
  kg.query() aggregates and in rule heads; the result column is still
  labelled `Count`.
- `col.in(other)` compiled to `rel(..., X, ...)`, which is not IQL. It now
  emits an atom of the target relation with the source variable in the
  target column and `_` elsewhere; `notIn` negates it. Rule conditions are
  compiled before the body atoms, so a filtered column that is not selected
  gets its variable in the atom instead of `_`.
- Meta commands come from one table (`meta`), pinned by the shared fixture
  packages/conformance/meta-commands.json that the Python SDK will render
  against too. session.listRules() sent `.session list` and dropRule(name,
  index) sent `.session remove`, both rejected; ruleDefinition() sent
  `.rule show`, which the engine reads as a query of a rule named `show`
  and answers empty; listRules() returned the `Rules:` header as a rule.
  They now send `.session`, `.session drop <n>` and `.rule def`, and parse
  the replies with the grammar the fixture records.
- The stale integration test expected a program mixing writes with
  `.rel drop` to apply partially; the engine rejects it as `unsupported`
  and applies nothing.

tests/integration.test.ts now runs in `make js-test-live` (the full-suite
js-sdk-live job) beside the docs query examples, including a test that
sends every command of the shared table to the engine and checks the
rejected spellings fail.
@jsam
jsam merged commit f5723bb into main Oct 4, 2026
18 checks passed
@jsam
jsam deleted the fm/sdk-p1-b branch October 4, 2026 11:46
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