Skip to content

feat(inputlayer-js): subscribe(), watch() and on() with typed Change events - #241

Merged
jsam merged 5 commits into
mainfrom
fm/sdk-p1-g
Oct 4, 2026
Merged

jsam merged 5 commits into
mainfrom
fm/sdk-p1-g

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; the SDK release blocker is that neither SDK has a subscription API. The agreed design is ~/firstmate/data/sdk-design/report.md; this item is its phase-1 PR P1-G (JS subscriptions), the TypeScript twin of P1-F.)

What P1-G is in that design: the JS SDK gets kg.subscribe() (an async iterator), kg.watch() (the whole current set each time it changes, with revision and a verified flag) and kg.on() (callbacks, with a handle to close), all built on typed Change events of one shape: kind snapshot | delta | unverified | resync, inserted and retracted rows, revision, seq, verified. The first event is the exact snapshot; deltas are set differences between consecutive results; a lost connection, a seq gap, a subscription_reset, a subscription_error or a slow consumer give an unverified event at once and then one resync event with the exact difference between the rows held and a fresh snapshot, after resubscribing with backoff (including resubscribing every subscription on reconnect). Streamed (chunked) snapshots and deltas are assembled and checked against announced counts; pushes with a stale generation are dropped and counted. Because the engine diffs full tuples, a projected subscription keeps a multiplicity per projected row and reports inserts and retracts only on 0<->1 transitions. Per-iterator queues are bounded (default 1024) and overflow never blocks the connection reader. subscribe() refuses limit/offset, OR splits and session views with SubscriptionRejected. TS and Python event shapes are to be identical. Live tests against a real engine in CI: snapshot+delta exactness over random histories (reusing the differential oracle's histories), gap, reset, slow consumer, ACL revoke, and projection_two_supporting_tuples. Other phase-1 items (claims/guards, the shared conformance fixtures, docs and README, the share="process" hub) are out of scope.

What Changed

  • Adds kg.subscribe() (an async iterator), kg.watch() (the whole current result with its revision and a verified flag) and kg.on() (a callback, returning a handle to close it). All three are built on the new Subscription in src/subscription.ts, which emits typed Change events: snapshot, delta, unverified or resync, each carrying inserted and retracted rows, revision, seq and verified. Each change is reported first as an unverified event and then as a resync that holds the exact difference against a fresh snapshot. The changes covered are lost connections, seq gaps, resets, subscription errors, slow consumers (the per-iterator queue is bounded) and stale-generation pushes. Projected subscriptions keep a count per row. A subscription to a query with limit/offset, OR branches, an aggregate or session rules is refused with the new SubscriptionRejectedError, which carries a typed reason.
  • In Connection, routeSubscription() takes an optional onStale hook, which runs whenever a stale-generation push is dropped. close() now emits a closed event without an error.
  • Adds unit tests and live integration tests for subscriptions: snapshot plus deltas over random histories, gaps, resets, slow consumers, reconnect and ACL revoke. The integration tests are added to make js-test-live.

Risk Assessment

✅ Low: The round-4 fix moves checkPersistent() into openWithBackoff's first-open attempt. A transient error from the .session listing (connection lost, deadline) now retries with the same backoff and state guard. A session_view SubscriptionRejectedError is not transient, so refusal() passes it through and the iterator still ends. The new test drops the socket on the first .session request and checks that the snapshot arrives on the reconnected socket, which is the behavior the decision asked for.

Testing

I started a disposable release inputlayer-server in a temporary data directory. Its Rust sources match head 519074b: the only changes since the commit it was built from are in Makefile, packages/ and gui docs. Against it I ran the live subscription suite (9/9 pass), connection.integration (7/7) and the subscription unit tests (31/31), which include the regressions for F1, F2, R2-1, R4-1 and watch-return. Separately I ran the outage demo with a real stop and restart: every check passed and watcher.return() no longer hangs. I also ran the idle watch-close repro, where return() resolves and the pending next() settles with done. I removed the temporary server binary and data directories afterwards, and the worktree is clean. The full make js-test-live recipe was not re-run: the docs-queries and guards files were left out, and the user had already confirmed integration.test.ts fails the same way on main.

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Snapshot plus deltas over the differential oracle's random histories equal a fresh query at every checkpoint ✅ pass live subscriptions-live-519074ba.txt: 'snapshot plus deltas equals a fresh query at every checkpoint of random histories'
projection_two_supporting_tuples: a projected row is retracted only when its last supporting tuple leaves ✅ pass live subscriptions-live-519074ba.txt; outage-demo-519074ba.txt projection PASS lines
Streamed (chunked) snapshot and deltas are assembled and checked against announced counts ✅ pass live subscriptions-live-519074ba.txt: 'assembles a streamed snapshot and streamed deltas'
A seq gap and a subscription_reset give unverified, then an exact resync ✅ pass live subscriptions-live-519074ba.txt: 'a seq gap and a reset end in unverified, then an exact resync'
A slow consumer gets unverified(slow_consumer), then a resync, without blocking the connection ✅ pass live subscriptions-live-519074ba.txt: 'a slow consumer gets unverified, then a resync once it has read the queue'
Real server stop/restart: unverified(connection_lost) at once, then a resync with only what changed; every subscription resubscribed; a subscription opened during the outage still yields its snapshot ✅ pass live outage-demo-519074ba.txt (ALL CHECKS PASSED); subscriptions-live-519074ba.txt reconnect test
An ACL revoke gives unverified(subscription_error), then SubscriptionRejectedError(access_denied) ✅ pass live subscriptions-live-519074ba.txt: 'an ACL revoke ends the subscription'
subscribe() refuses limit/offset, OR branches and session views with SubscriptionRejectedError carrying the right reason ✅ pass live subscriptions-live-519074ba.txt: 'refuses what a standing query cannot track'
watch() yields the whole set with revision and verified; on() delivers each Change and its handle closes it ✅ pass live subscriptions-live-519074ba.txt: 'watch yields the whole result; on calls back with each change'
Adversarial (watch-return-hang): closing an idle watch() while next() is pending resolves at once and settles next() with done ✅ pass live watch-close-repro-519074ba.txt ('return resolved; pending next: {"done":true}'); outage-demo-519074ba.txt reaches the end with no hang at watcher.return()
Evidence: Live subscription suite, verbose, 9/9
HEAD 519074ba941281a5531398f2692bff900fca9e56

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

(node:3419414) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
(node:3419414) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
(node:3419414) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
(node:3419414) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
(node:3419414) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
(node:3419414) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
 ✓ tests/subscriptions.integration.test.ts > Live: subscriptions > snapshot plus deltas equals a fresh query at every checkpoint of random histories 7717ms
 ✓ tests/subscriptions.integration.test.ts > Live: subscriptions > projection_two_supporting_tuples: a projected row leaves with its last support 150ms
 ✓ tests/subscriptions.integration.test.ts > Live: subscriptions > assembles a streamed snapshot and streamed deltas 367ms
 ✓ tests/subscriptions.integration.test.ts > Live: subscriptions > a seq gap and a reset end in unverified, then an exact resync 103ms
 ✓ tests/subscriptions.integration.test.ts > Live: subscriptions > a slow consumer gets unverified, then a resync once it has read the queue 580ms
 ✓ tests/subscriptions.integration.test.ts > Live: subscriptions > reconnects: unverified at once, then a resync with only what changed meanwhile 137ms
 ✓ tests/subscriptions.integration.test.ts > Live: subscriptions > an ACL revoke ends the subscription: unverified, then SubscriptionRejectedError 127ms
 ✓ tests/subscriptions.integration.test.ts > Live: subscriptions > refuses what a standing query cannot track, before or at the engine 45ms
 ✓ tests/subscriptions.integration.test.ts > Live: subscriptions > watch yields the whole result; on calls back with each change 51ms

 Test Files  1 passed (1)
      Tests  9 passed (9)
   Start at  13:22:21
   Duration  10.33s (transform 207ms, setup 0ms, collect 260ms, tests 9.80s, environment 0ms, prepare 75ms)
Evidence: Live subscriptions + connection integration + subscription unit tests
HEAD 519074ba941281a5531398f2692bff900fca9e56

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

(node:3414495) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
 ✓ tests/connection.integration.test.ts (7 tests) 658ms
(node:3414495) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
 ✓ tests/subscription.test.ts (31 tests) 2621ms
   ✓ subscribe > a connection lost before the first snapshot reopens on the new connection  836ms
   ✓ subscribe > a connection lost during the session-rule check still yields the snapshot  706ms
   ✓ subscribe > a .subscribe reply lost past its deadline is retried after unsubscribing the id  808ms
(node:3414495) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
(node:3414495) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
(node:3414495) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
(node:3414495) MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 disconnected listeners added to EventTarget. MaxListeners is 10. Use events.setMaxListeners() to increase limit
 ✓ tests/subscriptions.integration.test.ts (9 tests) 10098ms
   ✓ Live: subscriptions > snapshot plus deltas equals a fresh query at every checkpoint of random histories  7831ms
   ✓ Live: subscriptions > assembles a streamed snapshot and streamed deltas  452ms
   ✓ Live: subscriptions > a slow consumer gets unverified, then a resync once it has read the queue  584ms

 Test Files  3 passed (3)
      Tests  47 passed (47)
   Start at  13:22:02
   Duration  10.63s (transform 323ms, setup 0ms, collect 799ms, tests 13.38s, environment 1ms, prepare 228ms)
Evidence: Real server outage/restart demo transcript (ALL CHECKS PASSED, no hang at watcher.return)
HEAD 519074ba941281a5531398f2692bff900fca9e56
[13:22:37.046] server up pid 3421358
[13:22:37.187] subscribe(): snapshot rev=7 verified=true +[{"id":1,"owner":10},{"id":2,"owner":10}] -[]
[13:22:37.187] on(owner): snapshot rev=7 verified=true +[{"Owner":10}] -[]
[13:22:37.187] watch(): rows=[{"id":1,"owner":10},{"id":2,"owner":10}] rev=7 verified=true
[13:22:37.189] PASS first event is the exact snapshot (2 rows)
[13:22:37.189] PASS projected snapshot has owner 10 once
[13:22:37.189] --- write: +task(3,20), -task(1,10)
[13:22:37.192] subscribe(): delta rev=8 verified=true +[{"id":3,"owner":20}] -[]
[13:22:37.192] on(owner): delta rev=8 verified=true +[{"Owner":20}] -[]
[13:22:37.192] watch(): rows=[{"id":1,"owner":10},{"id":2,"owner":10},{"id":3,"owner":20}] rev=8 verified=true
[13:22:37.193] subscribe(): delta rev=9 verified=true +[] -[{"id":1,"owner":10}]
[13:22:37.193] watch(): rows=[{"id":2,"owner":10},{"id":3,"owner":20}] rev=9 verified=true
[13:22:37.213] PASS writes arrive as deltas
[13:22:37.214] PASS projection: owner 10 not retracted while task 2 still supports it
[13:22:37.214] --- real outage: stopping server process
[13:22:37.251] subscribe(): unverified(connection_lost) rev=9 verified=false +[] -[]
[13:22:37.251] on(owner): unverified(connection_lost) rev=9 verified=false +[] -[]
[13:22:37.251] watch(): rows=[{"id":2,"owner":10},{"id":3,"owner":20}] rev=9 verified=false
[13:22:37.254] server stopped
[13:22:37.254] PASS subscribe(): unverified(connection_lost) at once
[13:22:37.254] PASS watch(): verified=false during outage
[13:22:38.754] --- restarting server on same data dir
[13:22:38.856] subscribe(): resync rev=1 verified=true +[] -[]
[13:22:38.856] on(owner): resync rev=1 verified=true +[] -[]
[13:22:38.856] watch(): rows=[{"id":2,"owner":10},{"id":3,"owner":20}] rev=1 verified=true
[13:22:38.857] late subscribe(): snapshot rev=1 verified=true +[{"id":2,"owner":10},{"id":3,"owner":20}] -[]
[13:22:39.009] server up pid 3421810
[13:22:39.009] PASS subscription opened during outage yields its snapshot after restart
[13:22:39.009] PASS resync after restart replays nothing unchanged (empty diff)
[13:22:39.009] --- write after reconnect: +task(4,30), -task(2,10), -task(3,20)
[13:22:39.012] subscribe(): delta rev=4 verified=true +[{"id":4,"owner":30}] -[]
[13:22:39.013] on(owner): delta rev=4 verified=true +[{"Owner":30}] -[]
[13:22:39.013] late subscribe(): delta rev=4 verified=true +[{"id":4,"owner":30}] -[]
[13:22:39.013] watch(): rows=[{"id":2,"owner":10},{"id":3,"owner":20},{"id":4,"owner":30}] rev=4 verified=true
[13:22:39.014] subscribe(): delta rev=5 verified=true +[] -[{"id":2,"owner":10}]
[13:22:39.014] on(owner): delta rev=5 verified=true +[] -[{"Owner":10}]
[13:22:39.014] late subscribe(): delta rev=5 verified=true +[] -[{"id":2,"owner":10}]
[13:22:39.014] watch(): rows=[{"id":3,"owner":20},{"id":4,"owner":30}] rev=5 verified=true
[13:22:39.015] subscribe(): delta rev=6 verified=true +[] -[{"id":3,"owner":20}]
[13:22:39.015] on(owner): delta rev=6 verified=true +[] -[{"Owner":20}]
[13:22:39.015] late subscribe(): delta rev=6 verified=true +[] -[{"id":3,"owner":20}]
[13:22:39.015] watch(): rows=[{"id":4,"owner":30}] rev=6 verified=true
[13:22:39.036] PASS subscriber set equals fresh query ["[4,30]"]
[13:22:39.036] PASS watch(): whole set, verified again
[13:22:39.036] PASS projection: owner 10 retracted when its last support left
[13:22:39.037] closed iterator ended cleanly
[13:22:39.071] server stopped
[13:22:39.073] ALL CHECKS PASSED
Evidence: Outage demo script
/**
 * End-user drive of kg.subscribe()/watch()/on() against a real inputlayer-server,
 * including a real server outage (process stopped and restarted on the same data dir)
 * and a subscription opened while the server is down.
 * Run from packages/inputlayer-js: npx vite-node <this file>
 */
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { InputLayer, relation, type Change } from '~/.no-mistakes/worktrees/9eea230c3aae/01M43FP2QVZ84NFH1M5GPXHK5E/packages/inputlayer-js/src/index';

const ROOT = '~/.no-mistakes/worktrees/9eea230c3aae/01M43FP2QVZ84NFH1M5GPXHK5E';
const PORT = 8097;
const URL = `ws://127.0.0.1:${PORT}/ws`;
const dataDir = mkdtempSync(join(tmpdir(), 'il-sub-demo-'));
let server: ChildProcess | undefined;
const log = (...a: unknown[]) => console.log(`[${new Date().toISOString().slice(11, 23)}]`, ...a);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
let failures = 0;
const check = (ok: boolean, what: string) => {
  log(ok ? 'PASS' : 'FAIL', what);
  if (!ok) failures++;
};

async function startServer() {
  server = spawn(`/tmp/il-p1g-MKcd/inputlayer-server`, ['--host', '127.0.0.1', '--data-dir', dataDir, '--port', String(PORT)], {
    env: { ...process.env, INPUTLAYER_ADMIN_PASSWORD: 'admin' },
    stdio: 'ignore',
  });
  for (let i = 0; i < 80; i++) {
    try {
      if ((await fetch(`http://127.0.0.1:${PORT}/health`)).ok) return log('server up pid', server.pid);
    } catch {}
    await sleep(250);
  }
  throw new Error('server did not start');
}
async function stopServer() {
  const s = server!;
  const done = new Promise((r) => s.once('exit', r));
  s.kill('SIGTERM');
  await done;
  log('server stopped');
}
async function waitFor(p: () => boolean, ms = 15000) {
  const end = Date.now() + ms;
  while (!p()) {
    if (Date.now() > end) throw new Error('timeout');
    await sleep(20);
  }
}
const fmt = (c: Change) =>
  `${c.kind}${c.kind === 'unverified' ? `(${c.reason})` : ''} rev=${c.revision} verified=${c.verified} +${JSON.stringify(c.inserted)} -${JSON.stringify(c.retracted)}`;
const key = (r: object) => JSON.stringify(Object.values(r));

async function main() {
  await startServer();
  const opts = { url: URL, username: 'admin', password: 'admin', reconnectDelay: 0.05, maxReconnectDelay: 0.5, maxReconnectAttempts: 200 };
  const writerClient = new InputLayer(opts);
  await writerClient.connect();
  const writer = writerClient.knowledgeGraph('sub_demo');
  const Task = relation('Task', { id: 'int', owner: 'int' });
  await writer.define(Task);
  await writer.insert(Task, [{ id: 1, owner: 10 }, { id: 2, owner: 10 }]);

  const listenerClient = new InputLayer(opts);
  await listenerClient.connect();
  const kg = listenerClient.knowledgeGraph('sub_demo');

  // 1. async iterator
  const held = new Map<string, object>();
  const iterEvents: Change[] = [];
  const sub = kg.subscribe(Task);
  const iterDone = (async () => {
    for await (const c of sub) {
      iterEvents.push(c);
      log('subscribe():', fmt(c));
      for (const r of c.retracted) held.delete(key(r));
      for (const r of c.inserted) held.set(key(r), r);
    }
  })().catch((e) => log('subscribe() ended with', e));
  // 2. watch
  const watchSeen: Array<{ rows: unknown[]; verified: boolean; revision: number }> = [];
  const watcher = kg.watch(Task);
  const watchDone = (async () => {
    for await (const live of watcher) {
      watchSeen.push(live as never);
      log('watch():', `rows=${JSON.stringify(live.rows)} rev=${live.revision} verified=${live.verified}`);
    }
  })().catch((e) => log('watch() ended with', e));
  // 3. on (projected: owner only — two tasks share owner 10)
  const onEvents: Change[] = [];
  const handle = kg.on({ select: [Task.col('owner').toAst()], join: [Task] }, (c) => {
    onEvents.push(c);
    log('on(owner):', fmt(c));
  }, { onError: (e) => log('on() error', e) });

  await waitFor(() => iterEvents.length >= 1 && watchSeen.length >= 1 && onEvents.length >= 1);
  check(iterEvents[0].kind === 'snapshot' && iterEvents[0].inserted.length === 2, 'first event is the exact snapshot (2 rows)');
  check(onEvents[0].kind === 'snapshot' && onEvents[0].inserted.length === 1, 'projected snapshot has owner 10 once');

  log('--- write: +task(3,20), -task(1,10)');
  await writer.insert(Task, { id: 3, owner: 20 });
  await writer.delete(Task, { id: 1, owner: 10 });
  await waitFor(() => held.size === 2 && held.has('[3,20]') && !held.has('[1,10]'));
  check(iterEvents.slice(1).every((c) => c.kind === 'delta'), 'writes arrive as deltas');
  const ownerRetracts = onEvents.flatMap((c) => c.retracted);
  check(ownerRetracts.length === 0, 'projection: owner 10 not retracted while task 2 still supports it');

  log('--- real outage: stopping server process');
  await stopServer();
  await waitFor(() => iterEvents.some((c) => c.kind === 'unverified') && onEvents.some((c) => c.kind === 'unverified') && watchSeen.some((w) => !w.verified));
  check(iterEvents.at(-1)!.kind === 'unverified' && iterEvents.at(-1)!.reason === 'connection_lost', 'subscribe(): unverified(connection_lost) at once');
  check(watchSeen.at(-1)!.verified === false, 'watch(): verified=false during outage');

  // 4. a subscription opened while the server is down (first open must survive the outage)
  const lateEvents: Change[] = [];
  const late = kg.subscribe(Task);
  const lateDone = (async () => {
    for await (const c of late) {
      lateEvents.push(c);
      log('late subscribe():', fmt(c));
    }
  })().catch((e) => {
    log('late subscribe() ended with', e);
    lateEvents.push({ kind: 'ERROR' } as never);
  });
  await sleep(1500);

  log('--- restarting server on same data dir');
  await startServer();
  await waitFor(() => iterEvents.at(-1)!.kind === 'resync' && onEvents.at(-1)!.kind === 'resync' && lateEvents.length >= 1, 20000);
  check(lateEvents[0].kind === 'snapshot' && lateEvents[0].inserted.length === 2, 'subscription opened during outage yields its snapshot after restart');
  const resync = iterEvents.at(-1)!;
  check(resync.inserted.length === 0 && resync.retracted.length === 0, 'resync after restart replays nothing unchanged (empty diff)');

  log('--- write after reconnect: +task(4,30), -task(2,10), -task(3,20)');
  await writer.insert(Task, { id: 4, owner: 30 });
  await writer.delete(Task, { id: 2, owner: 10 });
  await writer.delete(Task, { id: 3, owner: 20 });
  await waitFor(() => held.size === 1 && held.has('[4,30]'));
  await waitFor(() => onEvents.flatMap((c) => c.retracted).some((r) => key(r) === '[10]'));
  const fresh = (await writer.execute('?task(I, O)')).toTuples().map((t) => JSON.stringify(t)).sort();
  check(JSON.stringify([...held.keys()].sort()) === JSON.stringify(fresh), `subscriber set equals fresh query ${JSON.stringify(fresh)}`);
  check(watchSeen.at(-1)!.verified && JSON.stringify(watchSeen.at(-1)!.rows) === JSON.stringify([{ id: 4, owner: 30 }]), 'watch(): whole set, verified again');
  check(onEvents.flatMap((c) => c.retracted).some((r) => key(r) === '[10]'), 'projection: owner 10 retracted when its last support left');

  await sub.close();
  await watcher.return(undefined);
  await handle.close();
  await late.close();
  await Promise.all([iterDone, watchDone, lateDone]);
  log('closed iterator ended cleanly');
  await writerClient.dropKnowledgeGraph('sub_demo').catch(() => undefined);
  await listenerClient.close();
  await writerClient.close();
}

main()
  .catch((e) => {
    failures++;
    log('ERROR', e);
  })
  .finally(async () => {
    if (server && server.exitCode === null) await stopServer();
    rmSync(dataDir, { recursive: true, force: true });
    log(failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`);
    process.exit(failures === 0 ? 0 : 1);
  });
Evidence: Idle watch().return() repro output

first {"rows":[],"revision":225,"verified":true} return resolved; pending next: {"done":true}

first {"rows":[],"revision":225,"verified":true}
return resolved; pending next: {"done":true}
- Outcome: 🔧 1 issue found → auto-fixed ✅ across 3 runs (11m12s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed (3) ✅
  • ⚠️ packages/inputlayer-js/src/subscription.ts:377 - A dropped connection during the first open ends the subscription for good, even though the connection reconnects. Sequence: kg.subscribe(E) → next() → start() → open('snapshot') sends .subscribe. The socket drops before the reply, and Connection.onClose → failInFlight rejects the call with ConnectionLostError (connection.ts:946). start() catches the error and calls fail(e), so the iterator throws ConnectionLostError. With on(), the callback stream stops after a single onError. onDisconnected also does nothing while state is 'opening' (subscription.ts:582-585). The docs say only 'losing the connection for good' ends the iterator, and the intent says a lost connection leads to resubscribing with backoff. A large snapshot makes this window wide. Fix: in start(), retry transient errors (transient(e), excluding a closed connection) with the same backoff loop reopen() uses, still with kind 'snapshot' so the first event stays the snapshot. Same class: a DeadlineExceeded or InternalError on the initial open also ends the subscription instead of retrying.
  • ⚠️ packages/inputlayer-js/src/subscription.ts:464 - A resync can fail permanently after a timeout. If the .subscribe request reached the server but the reply was lost locally (deadline overdue: connection.ts:594-600 abandons the call after the grace period; or the InternalError thrown for a reply with no subscribed, line 424), reopen() treats the error as transient. It retries with registered still false, so it skips the .unsubscribe. The engine still holds the id, so the next .subscribe with the same id is refused with 'already exists on this connection'. refusal() maps that to reason 'id_taken', which is not transient, so the subscription ends with SubscriptionRejectedError instead of resyncing. Fix: in reopen()'s catch, and in open()'s catch at line 411 for errors after send, set this.registered = true for any transient error other than ConnectionLostError. The next attempt's .unsubscribe already ignores 'not found' errors (line 455-458). The same leaked registration happens on a timed-out initial open (start → fail with registered=false, so end() never unsubscribes).
  • ℹ️ packages/inputlayer-js/src/subscription.ts:30 - The intent requires the TS and Python event shapes to be identical (kinds, fields, UnverifiedReason values, SubscriptionRejectedReason values). The Python twin (P1-F) is not in this tree; inputlayer-py has only _meta.subscribe. So parity can't be checked here. Compare the two when P1-F lands.

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

  • ⚠️ packages/inputlayer-js/src/subscription.ts:377 - A dropped connection during the first open ends the subscription for good, even though the connection reconnects. Sequence: kg.subscribe(E) → next() → start() → open('snapshot') sends .subscribe. The socket drops before the reply, and Connection.onClose → failInFlight rejects the call with ConnectionLostError (connection.ts:946). start() catches the error and calls fail(e), so the iterator throws ConnectionLostError. With on(), the callback stream stops after a single onError. onDisconnected also does nothing while state is 'opening' (subscription.ts:582-585). The docs say only 'losing the connection for good' ends the iterator, and the intent says a lost connection leads to resubscribing with backoff. A large snapshot makes this window wide. Fix: in start(), retry transient errors (transient(e), excluding a closed connection) with the same backoff loop reopen() uses, still with kind 'snapshot' so the first event stays the snapshot. Same class: a DeadlineExceeded or InternalError on the initial open also ends the subscription instead of retrying.
  • ⚠️ packages/inputlayer-js/src/subscription.ts:464 - A resync can fail permanently after a timeout. If the .subscribe request reached the server but the reply was lost locally (deadline overdue: connection.ts:594-600 abandons the call after the grace period; or the InternalError thrown for a reply with no subscribed, line 424), reopen() treats the error as transient. It retries with registered still false, so it skips the .unsubscribe. The engine still holds the id, so the next .subscribe with the same id is refused with 'already exists on this connection'. refusal() maps that to reason 'id_taken', which is not transient, so the subscription ends with SubscriptionRejectedError instead of resyncing. Fix: in reopen()'s catch, and in open()'s catch at line 411 for errors after send, set this.registered = true for any transient error other than ConnectionLostError. The next attempt's .unsubscribe already ignores 'not found' errors (line 455-458). The same leaked registration happens on a timed-out initial open (start → fail with registered=false, so end() never unsubscribes).
  • ℹ️ packages/inputlayer-js/src/subscription.ts:30 - The intent requires the TS and Python event shapes to be identical (kinds, fields, UnverifiedReason values, SubscriptionRejectedReason values). The Python twin (P1-F) is not in this tree; inputlayer-py has only _meta.subscribe. So parity can't be checked here. Compare the two when P1-F lands.
  • ⚠️ packages/inputlayer-js/src/subscription.ts:414 - The round-1 fix for F2 left one sibling case open: a subscription closed while a .subscribe is in flight can stay registered on the server. The fix added line 414, which sets registered = true when a .subscribe fails transiently, but it does not check whether the subscription is already closed. Sequence: kg.on(E, cb) or kg.subscribe(E) starts open('snapshot'). The user calls handle.close() before the reply arrives. end() runs while registered is false, so it sends no .unsubscribe. The reply then misses its deadline (DeadlineExceededError or OutcomeUnknownError after timeoutGraceMs), even though the engine registered the id. open()'s catch sets registered=true, and openWithBackoff returns because state is 'closed'. Nothing ever sends .unsubscribe. The engine keeps evaluating the standing query and pushing to this connection for the rest of the connection's life. That is a resource leak for long-lived agent connections. The success path already covers this case (lines 417-422: closed while opening → unsubscribe()). Fix: in open()'s catch, after marking it registered, call this.unsubscribe() when this.state === &#39;closed&#39;, the same as lines 417-422. The resync path has the same problem through the same line.

🔧 Fix applied.
1 info still open:

  • ℹ️ packages/inputlayer-js/src/subscription.ts:30 - The intent requires the TS and Python event shapes to be identical (kinds, fields, UnverifiedReason values, SubscriptionRejectedReason values). The Python twin (P1-F) is not in this tree; inputlayer-py has only _meta.subscribe. So parity can't be checked here. Compare the two when P1-F lands.

  • ⚠️ packages/inputlayer-js/src/subscription.ts:378 - Round 2 fixed F1 by retrying the first .subscribe through openWithBackoff, but it missed a sibling path: the session-rule check that runs before it. start() awaits checkPersistent(), which calls .session list through Session.listRules → conn.execute. It does this for every RelationDef or QueryOptions target, which is the common case. Any error there goes straight to fail(e), including transient ones (ConnectionLostError when the socket drops mid-request, or DeadlineExceededError when the call waits in the queue during a reconnect that takes longer than the default timeout). Sequence: kg.subscribe(E) → next() → start() → listRules is in flight → the socket drops with autoReconnect on → failInFlight rejects it with ConnectionLostError → the iterator throws, and on() stops after one onError, even though the connection reconnects. This is the same failure F1 described ("a dropped connection during the first open ends the subscription for good"), now one step earlier. Fix: in start()'s catch, call fail() only when !transient(e) or the error is a closed-connection ConnectionError. For a transient error, retry the check with the same backoff, or skip the check: a reconnect resets the session, so the session rules are gone anyway. The window is narrow (one quick round trip, or a slow reconnect).

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

🔧 **Test** - 1 issue found → auto-fixed ✅
  • ℹ️ packages/inputlayer-js/src/subscription.ts:374 - With more than 10 subscriptions on one connection, Node prints 'MaxListenersExceededWarning: 11 disconnected listeners added to EventTarget'. Each Subscription adds a 'disconnected' and a 'closed' listener to conn.events; they are removed on end, so this is not a leak. Users with many subscriptions will still see the console warning. events.setMaxListeners could suppress it.
  • Live validation: ✅ go - 12 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Snapshot plus deltas over seeded random oracle histories always equal a fresh query (plain and projected subscriptions), with no unverified events ✅ pass live subscriptions-live.txt: 'snapshot plus deltas equals a fresh query at every checkpoint of random histories'
projection_two_supporting_tuples: a projected row is retracted only when its last supporting tuple goes ✅ pass live subscriptions-live.txt
Streamed (chunked) snapshot and deltas are assembled correctly ✅ pass live subscriptions-live.txt: 'assembles a streamed snapshot and streamed deltas'
A seq gap and a subscription_reset give an unverified event, then an exact resync ✅ pass live subscriptions-live.txt
A slow consumer that overflows its bounded queue gets unverified, then a resync once it drains ✅ pass live subscriptions-live.txt
A reconnect gives an unverified event at once, then a resync with only what changed meanwhile ✅ pass live subscriptions-live.txt
An ACL revoke ends the subscription with unverified, then SubscriptionRejectedError ✅ pass live subscriptions-live.txt
limit/offset, OR and session views are refused with SubscriptionRejectedError (typed reason) ✅ pass live subscriptions-live.txt refusal test; transcript.txt D: 'limit refused: SubscriptionRejectedError reason=limit_offset'
User flow: subscribe yields snapshot then delta, watch yields the whole set with revision and verified, on calls back for each change ✅ pass live transcript.txt scenario D
Adversarial (round-1 F1): socket dropped before the first .subscribe reply; the SDK reconnects and the first event is still the exact snapshot, with no error ✅ pass live transcript.txt scenario A (disconnected, reconnected, .subscribe again, snapshot {1,2}, delta +3)
Adversarial (round-1 F2): .subscribe reply lost past its deadline; the retry sends .unsubscribe first and succeeds instead of failing with id_taken ✅ pass live transcript.txt scenario B: wire order .subscribe, .unsubscribe (engine replied 'Unsubscribed'), .subscribe, then snapshot
Adversarial (round-2 R2-1): close() while the .subscribe reply is lost; .unsubscribe still reaches the engine, so nothing leaks ✅ pass live transcript.txt scenario C: engine replied "Unsubscribed 'il_sub_3'" after close()
  • cargo build --release --bin inputlayer-server, then started it with --host 127.0.0.1 --port 18091 --data-dir &lt;mktemp&gt; and INPUTLAYER_ADMIN_PASSWORD=admin

  • INPUTLAYER_TEST_SERVER=ws://127.0.0.1:18091/ws npx vitest run --reporter=verbose tests/subscriptions.integration.test.ts (9 live tests against the real engine)

  • npx vitest run --reporter=verbose tests/subscription.test.ts (mock-server unit tests, including the round-1 and round-2 regression tests)

  • node live-proxy/drive.cjs: a WebSocket fault-injecting proxy between the SDK and the real engine. Scenario A drops the socket after the first .subscribe is sent, before its reply. Scenario B swallows the first .subscribe reply past timeoutMs + grace. Scenario C calls close() while the reply is swallowed. Scenario D is a plain subscribe/watch/on flow with writes from a second client, plus a limit refusal.

  • ⚠️ No live evidence was gathered. The release inputlayer-server build was still compiling when this phase had to return, so the live subscription suite (make js-test-live, tests/subscriptions.integration.test.ts) and the outage demo (~/.no-mistakes/evidence/01M43FP2QVZ84NFH1M5GPXHK5E/outage-demo.ts, run with npx vite-node from packages/inputlayer-js) did not run. Rerun this phase, or run make js-test-live, once the binary is built.

  • ⚠️ live validation verdict: inconclusive (0 of 9 scenarios were driven live against the product); untested: Snapshot plus deltas over the differential oracle's random histories equal a fresh query at every checkpoint, projection_two_supporting_tuples: a projected row is retracted only when its last supporting tuple leaves, Streamed (chunked) snapshot and deltas are assembled and checked against announced counts, A seq gap and a subscription_reset give unverified, then an exact resync, A slow consumer with queue=2 gets unverified(slow_consumer), then a resync, without blocking the connection, A server outage and restart give unverified(connection_lost) at once, then a resync with only what changed. All subscriptions are resubscribed, and a subscription opened while the server is down still yields its snapshot (F1/R4-1), An ACL revoke gives unverified(subscription_error), then SubscriptionRejectedError(access_denied), subscribe() refuses limit/offset, OR branches and session views with SubscriptionRejectedError carrying the right reason, watch() yields the whole set with revision and verified; on() delivers each Change and its handle closes it

  • Live validation: ⚠️ inconclusive - 0 of 9 scenarios driven live against the product

Scenario Result Live Evidence
Snapshot plus deltas over the differential oracle's random histories equal a fresh query at every checkpoint ⏸️ untested no Needs target/release/inputlayer-server. The cargo release build was started but was still compiling when this phase had to return. Run make js-test-live once it finishes.
projection_two_supporting_tuples: a projected row is retracted only when its last supporting tuple leaves ⏸️ untested no The server binary build had not finished, so no live engine was available.
Streamed (chunked) snapshot and deltas are assembled and checked against announced counts ⏸️ untested no The server binary build had not finished, so no live engine was available.
A seq gap and a subscription_reset give unverified, then an exact resync ⏸️ untested no The server binary build had not finished, so no live engine was available.
A slow consumer with queue=2 gets unverified(slow_consumer), then a resync, without blocking the connection ⏸️ untested no The server binary build had not finished, so no live engine was available.
A server outage and restart give unverified(connection_lost) at once, then a resync with only what changed. All subscriptions are resubscribed, and a subscription opened while the server is down still… ⏸️ untested no The server binary build had not finished, so no live engine was available. The mock unit tests are not live evidence.
An ACL revoke gives unverified(subscription_error), then SubscriptionRejectedError(access_denied) ⏸️ untested no The server binary build had not finished, so no live engine was available.
subscribe() refuses limit/offset, OR branches and session views with SubscriptionRejectedError carrying the right reason ⏸️ untested no The server binary build had not finished, so no live engine was available.
watch() yields the whole set with revision and verified; on() delivers each Change and its handle closes it ⏸️ untested no The server binary build had not finished, so no live engine was available.
  • npx vitest run tests/subscription.test.ts (packages/inputlayer-js, mock WebSocket server): 30/30 passed, including the regression tests for F1, F2, R2-1 and R4-1
  • cargo build --release --bin inputlayer-server (started in the background; still compiling when this phase had to return)
  • Wrote the end-user outage demo ~/.no-mistakes/evidence/01M43FP2QVZ84NFH1M5GPXHK5E/outage-demo.ts. It covers subscribe/watch/on, a real server stop and restart, a subscription opened while the server is down, and projection multiplicity. It has not been run yet.

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

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Snapshot plus deltas over the differential oracle's random histories equal a fresh query at every checkpoint ✅ pass live subscriptions-live-519074ba.txt: 'snapshot plus deltas equals a fresh query at every checkpoint of random histories'
projection_two_supporting_tuples: a projected row is retracted only when its last supporting tuple leaves ✅ pass live subscriptions-live-519074ba.txt; outage-demo-519074ba.txt projection PASS lines
Streamed (chunked) snapshot and deltas are assembled and checked against announced counts ✅ pass live subscriptions-live-519074ba.txt: 'assembles a streamed snapshot and streamed deltas'
A seq gap and a subscription_reset give unverified, then an exact resync ✅ pass live subscriptions-live-519074ba.txt: 'a seq gap and a reset end in unverified, then an exact resync'
A slow consumer gets unverified(slow_consumer), then a resync, without blocking the connection ✅ pass live subscriptions-live-519074ba.txt: 'a slow consumer gets unverified, then a resync once it has read the queue'
Real server stop/restart: unverified(connection_lost) at once, then a resync with only what changed; every subscription resubscribed; a subscription opened during the outage still yields its snapshot ✅ pass live outage-demo-519074ba.txt (ALL CHECKS PASSED); subscriptions-live-519074ba.txt reconnect test
An ACL revoke gives unverified(subscription_error), then SubscriptionRejectedError(access_denied) ✅ pass live subscriptions-live-519074ba.txt: 'an ACL revoke ends the subscription'
subscribe() refuses limit/offset, OR branches and session views with SubscriptionRejectedError carrying the right reason ✅ pass live subscriptions-live-519074ba.txt: 'refuses what a standing query cannot track'
watch() yields the whole set with revision and verified; on() delivers each Change and its handle closes it ✅ pass live subscriptions-live-519074ba.txt: 'watch yields the whole result; on calls back with each change'
Adversarial (watch-return-hang): closing an idle watch() while next() is pending resolves at once and settles next() with done ✅ pass live watch-close-repro-519074ba.txt ('return resolved; pending next: {"done":true}'); outage-demo-519074ba.txt reaches the end with no hang at watcher.return()
  • npx vitest run --reporter=verbose tests/subscriptions.integration.test.ts against a live server (INPUTLAYER_TEST_SERVER=ws://127.0.0.1:8091/ws)
  • npx vitest run tests/subscriptions.integration.test.ts tests/connection.integration.test.ts tests/subscription.test.ts against the live server
  • npx vite-node outage-demo-519074ba.ts (real server stopped and restarted on the same data dir)
  • npx vite-node watch-close-repro-519074ba.ts (closing an idle watch against the live server)
✅ **Document** - passed

✅ No issues found.

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

jsam added 5 commits October 4, 2026 13:07
…events

kg.subscribe(target) is an async iterator of Change events of one shape
(inserted, retracted, revision, seq, kind, verified, reason): the snapshot,
then deltas, and around anything that breaks the stream an `unverified`
event at once followed by one exact `resync`. The target is a relation or
view, a query, or raw IQL.

- Streamed deltas are assembled and checked against their end's counts;
  pushes of an earlier generation are dropped and counted; a seq gap, a
  broken stream, subscription_reset and subscription_error all reopen the
  subscription with backoff (unsubscribing first where the server still
  holds it).
- Projected queries keep a count of the engine rows behind each row and
  report it only on 0<->1 transitions, so a row with another supporting
  tuple is never retracted.
- Per-iterator queues are bounded (default 1024); a consumer that falls
  behind gets unverified (slow_consumer), and the resync is taken once it
  has read the queue.
- A reconnect is unverified at once, then a resync with only what changed;
  a connection closed for good ends the iterator with ConnectionLostError,
  a client close ends it quietly (Connection.close() now emits `closed`).
- limit/offset, OR splits, aggregates and session rules are refused with
  SubscriptionRejectedError before anything is sent; engine refusals are
  typed by reason (result_cap, access_denied, id_taken, ...).
- kg.watch() yields the whole current set with its revision and verified
  flag; kg.on() feeds each change to a callback.

Tests: mock-server unit tests for every path above, and live tests (added
to make js-test-live) running the differential oracle's seeded random
histories with a fresh query as the reference, plus projection with two
supporting tuples, streamed snapshot and deltas, gap, reset, slow consumer,
reconnect, ACL revoke and the refusals.
@jsam
jsam marked this pull request as ready for review October 4, 2026 13:29
@jsam
jsam merged commit 8bc22a4 into main Oct 4, 2026
20 checks passed
@jsam
jsam deleted the fm/sdk-p1-g branch October 4, 2026 13:30
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