Repository navigation
feat(inputlayer-js): subscribe(), watch() and on() with typed Change events - #241
Merged
Merged
Conversation
…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
marked this pull request as ready for review
October 4, 2026 13:29
This was referenced Oct 4, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
kg.subscribe()(an async iterator),kg.watch()(the whole current result with its revision and averifiedflag) andkg.on()(a callback, returning a handle to close it). All three are built on the newSubscriptioninsrc/subscription.ts, which emits typedChangeevents:snapshot,delta,unverifiedorresync, each carrying inserted and retracted rows,revision,seqandverified. Each change is reported first as anunverifiedevent and then as aresyncthat 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 withlimit/offset, OR branches, an aggregate or session rules is refused with the newSubscriptionRejectedError, which carries a typedreason.Connection,routeSubscription()takes an optionalonStalehook, which runs whenever a stale-generation push is dropped.close()now emits aclosedevent without anerror.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.
Evidence: Live subscription suite, verbose, 9/9
Evidence: Live subscriptions + connection integration + subscription unit tests
Evidence: Real server outage/restart demo transcript (ALL CHECKS PASSED, no hang at watcher.return)
Evidence: Outage demo script
Evidence: Idle watch().return() repro output
first {"rows":[],"revision":225,"verified":true} return resolved; pending next: {"done":true}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 nosubscribed, line 424), reopen() treats the error as transient. It retries withregisteredstill 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, setthis.registered = truefor 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 nosubscribed, line 424), reopen() treats the error as transient. It retries withregisteredstill 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, setthis.registered = truefor 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 setsregistered = truewhen 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, callthis.unsubscribe()whenthis.state === 'closed', 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.subscribethrough openWithBackoff, but it missed a sibling path: the session-rule check that runs before it. start() awaits checkPersistent(), which calls.session listthrough Session.listRules → conn.execute. It does this for every RelationDef or QueryOptions target, which is the common case. Any error there goes straight tofail(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.cargo build --release --bin inputlayer-server, then started it with--host 127.0.0.1 --port 18091 --data-dir <mktemp>and INPUTLAYER_ADMIN_PASSWORD=adminINPUTLAYER_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.make js-test-live, tests/subscriptions.integration.test.ts) and the outage demo (~/.no-mistakes/evidence/01M43FP2QVZ84NFH1M5GPXHK5E/outage-demo.ts, run withnpx vite-nodefrom packages/inputlayer-js) did not run. Rerun this phase, or runmake js-test-live, once the binary is built.Live validation:⚠️ inconclusive - 0 of 9 scenarios driven live against the product
make js-test-liveonce it finishes.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-1cargo 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.
npx vitest run --reporter=verbose tests/subscriptions.integration.test.tsagainst 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.tsagainst the live servernpx 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.