<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:fh="http://purl.org/syndication/history/1.0"><channel><title>TypeGraph | Blog</title><description>TypeScript-first embedded knowledge graph library with reasoning</description><link>https://typegraph.dev/</link><language>en</language><fh:complete/><atom:link rel="self" href="https://typegraph.dev/blog/rss.xml"/><item><title>Look Before You Write</title><link>https://typegraph.dev/blog/store-analysis-and-guarded-writes/</link><guid isPermaLink="true">https://typegraph.dev/blog/store-analysis-and-guarded-writes/</guid><description>How an agent can find the rows that no longer fit a tightened schema and repair them without overwriting a person&apos;s edit, using store.describe(), validateStore(), and compareAndSet().</description><pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Suppose you tighten the schema on an account directory so that &lt;code dir=&quot;auto&quot;&gt;email&lt;/code&gt; has to
be a real address, &lt;code dir=&quot;auto&quot;&gt;plan&lt;/code&gt; has to be one of three values, and &lt;code dir=&quot;auto&quot;&gt;seats&lt;/code&gt; has to be
positive. TypeGraph validates on write, so everything written from then on is
checked, but rows stored before the change are never looked at again, and you
have no idea how many of them fail the new rules. You’d like a background
agent (or yourself, in a REPL) to find and fix them while sales reps carry on
editing the same accounts.&lt;/p&gt;
&lt;p&gt;To do that safely the agent needs a cheap overview of what’s in the graph, a
way to find the rows that don’t validate without reading everything back into
memory, and a write that won’t clobber a change a person made after the agent
read the row.&lt;/p&gt;
&lt;p&gt;0.54 added &lt;code dir=&quot;auto&quot;&gt;store.describe()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;store.validateStore()&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;compareAndSet()&lt;/code&gt;
for those three jobs, plus &lt;code dir=&quot;auto&quot;&gt;planCandidateWriteSet()&lt;/code&gt; for reviewing a whole
batch before it lands, and 0.64 lets the first two run inside a transaction.
It’s the same loop &lt;a href=&quot;https://typegraph.dev/blog/runtime-schema-evolution&quot;&gt;runtime schema evolution&lt;/a&gt;
uses when an agent proposes changes to the schema itself: the agent looks at
the current state, proposes a change, and the change only applies if nothing
has moved since it looked.&lt;/p&gt;
&lt;p&gt;The graph here is 240 accounts and 90 people created through the store, 60
&lt;code dir=&quot;auto&quot;&gt;memberOf&lt;/code&gt; edges, and three legacy &lt;code dir=&quot;auto&quot;&gt;Account&lt;/code&gt; rows inserted through the backend
directly, the way a writer running the old rules would have stored them. The
outputs below come from running this code against it on SQLite.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;Account&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineNode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Account&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schema: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;name: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;min&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;email: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;email&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;plan: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;enum&lt;/span&gt;&lt;span&gt;([&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;free&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;team&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;enterprise&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;])&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;seats: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;number&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positive&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ownerId: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;optional&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;whats-in-the-graph&quot;&gt;What’s in the graph&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const { &lt;/span&gt;&lt;span&gt;statistics&lt;/span&gt;&lt;span&gt; } = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;describe&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;kind&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;of&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;statistics&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;statistics&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;edges&lt;/span&gt;&lt;span&gt;]) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;console&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;log&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;kind&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;entity&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;kind&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;kind&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;kind&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;count&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;property&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;of&lt;/span&gt;&lt;span&gt; kind&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;properties&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;console&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;log&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;property&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;path&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;  present=&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;property&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;presentCount&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;  coverage=&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;property&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;coverage&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;toFixed&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;node Account: 243&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;/email  present=243  coverage=1.00&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;/name  present=243  coverage=1.00&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;/ownerId  present=60  coverage=0.25&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;/plan  present=243  coverage=1.00&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;/seats  present=243  coverage=1.00&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;node Person: 90&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;/name  present=90  coverage=1.00&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;/title  present=30  coverage=0.33&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;edge memberOf: 60&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;/role  present=30  coverage=0.50&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Every declared kind gets a row count, and every declared property gets a
presence count and a coverage figure. &lt;code dir=&quot;auto&quot;&gt;/ownerId&lt;/code&gt; at 0.25 says 60 of 243
accounts have an owner, which is enough for an agent to decide where to spend
its attention before it reads a single row.&lt;/p&gt;
&lt;p&gt;The counting happens in the database, and on this graph &lt;code dir=&quot;auto&quot;&gt;describe()&lt;/code&gt; runs two
statements, one for all node kinds and one for all edge kinds. The result also
records the schema version and hash it was computed under, and TypeGraph
checks that they didn’t change mid-way, so you never get statistics that
straddle a schema change.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-no-longer-fits&quot;&gt;What no longer fits&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;let &lt;/span&gt;&lt;span&gt;cursor&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;undefined&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;do&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;validateStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;entity: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;node&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Account&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;pageSize: &lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;(cursor&lt;/span&gt;&lt;span&gt; === &lt;/span&gt;&lt;span&gt;undefined&lt;/span&gt;&lt;span&gt; ? {} : { &lt;/span&gt;&lt;span&gt;cursor&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;console&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;log&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;scanned &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scannedCount&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;violations&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; violation(s)&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;failure&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;of&lt;/span&gt;&lt;span&gt; page&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;violations&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;console&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;log&lt;/span&gt;&lt;span&gt;(failure&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, failure&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;path&lt;/span&gt;&lt;span&gt;, failure&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;reason&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cursor &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; page&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nextCursor&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;while&lt;/span&gt;&lt;span&gt; (cursor &lt;/span&gt;&lt;span&gt;!==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;undefined&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;scanned 100, 0 violation(s)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;scanned 100, 0 violation(s)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;scanned 43, 3 violation(s)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;acct_legacy_1 /email Invalid email address&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;acct_legacy_2 /plan Invalid option: expected one of &quot;free&quot;|&quot;team&quot;|&quot;enterprise&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;acct_legacy_2 /seats Too small: expected number to be &gt;0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;There are three violations across two records, because &lt;code dir=&quot;auto&quot;&gt;acct_legacy_2&lt;/code&gt; breaks
two rules. Each failure carries the record id, a JSON pointer to the property,
the Zod issue code, and the reason. Each page is one bounded statement, and the cursor is tied to the
schema it started under. If the schema changes mid-sweep, the next page throws
&lt;code dir=&quot;auto&quot;&gt;StoreAnalysisCursorStaleError&lt;/code&gt; instead of quietly checking the rest against
different rules.&lt;/p&gt;
&lt;p&gt;The third legacy row, &lt;code dir=&quot;auto&quot;&gt;acct_legacy_3&lt;/code&gt;, isn’t reported. It carries a
&lt;code dir=&quot;auto&quot;&gt;salesforceId&lt;/code&gt; the schema doesn’t declare, and undeclared properties count as
healthy extra data, so you can sweep a graph whose shape is changing at runtime
without every extra field showing up as a defect.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;fix-a-row-without-racing-anyone&quot;&gt;Fix a row without racing anyone&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The obvious repair is to call &lt;code dir=&quot;auto&quot;&gt;getById()&lt;/code&gt;, decide what to change, and call
&lt;code dir=&quot;auto&quot;&gt;update()&lt;/code&gt;, but a sales rep’s edit that lands between the read and the write
gets overwritten. &lt;code dir=&quot;auto&quot;&gt;compareAndSet()&lt;/code&gt; closes that gap by checking the expected
values and applying the update in one statement.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;applied&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Account&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;compareAndSet&lt;/span&gt;&lt;span&gt;(id&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;expected: { name: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Northwind&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, plan: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;team&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, seats: &lt;/span&gt;&lt;span&gt;12&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;patch: { email: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;ops@northwind.example&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;applied=true  email=ops@northwind.example  version 1 -&gt; 2&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The interesting case is when someone else gets there first. Say the agent read
&lt;code dir=&quot;auto&quot;&gt;acct_legacy_2&lt;/code&gt; and planned a repair, but before it applied, a person fixed
the row by hand and changed the email along the way. The agent’s guard names the email it read:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;applied&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Account&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;compareAndSet&lt;/span&gt;&lt;span&gt;(id&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;expected: { name: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Contoso&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, email: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;it@contoso.example&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;patch: { plan: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;team&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, seats: &lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;applied=false  seats=20  version 2 -&gt; 2  updatedAt changed=false&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;false&lt;/code&gt; means nothing was written, so the person’s &lt;code dir=&quot;auto&quot;&gt;seats=20&lt;/code&gt; stands and the
version didn’t move. It’s an ordinary return value rather than an exception,
and the agent should re-read the row and plan again.&lt;/p&gt;
&lt;p&gt;To require that a property is &lt;em&gt;missing&lt;/em&gt;, use the exported
&lt;code dir=&quot;auto&quot;&gt;compareAndSetAbsent&lt;/code&gt; marker (&lt;code dir=&quot;auto&quot;&gt;undefined&lt;/code&gt; is too easy to lose while an object
is being built or serialized). Here are two agents trying to claim the same
unowned account:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;claim&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;ownerId&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Account&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;compareAndSet&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;acct_1&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;expected: { ownerId: &lt;/span&gt;&lt;span&gt;compareAndSetAbsent&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;patch: { &lt;/span&gt;&lt;span&gt;ownerId&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;console&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;log&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;claim&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;rep-9&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;), &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;claim&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;rep-4&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;true false&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The first agent gets the account and the second finds out immediately,
without a lock or a retry loop.&lt;/p&gt;
&lt;p&gt;Expected values are checked against the property’s current schema, so you
can’t guard on a value that’s already invalid, which is why the repair above
guards on &lt;code dir=&quot;auto&quot;&gt;name&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;plan&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;seats&lt;/code&gt; rather than the bad email. The row also
has to be valid as a whole after the patch, so patching only &lt;code dir=&quot;auto&quot;&gt;plan&lt;/code&gt; on
&lt;code dir=&quot;auto&quot;&gt;acct_legacy_2&lt;/code&gt; fails on &lt;code dir=&quot;auto&quot;&gt;seats&lt;/code&gt; before anything is written.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;review-a-batch-before-it-lands&quot;&gt;Review a batch before it lands&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A guard works for one row, but a partner feed offering a batch of accounts
is more of a review problem, where you want to see what would change before
anything does. &lt;code dir=&quot;auto&quot;&gt;planCandidateWriteSet()&lt;/code&gt; takes a serializable batch of nodes
and edges, stages it on a throwaway copy, and hands back an ordinary merge plan
without ever writing to the target.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;plan&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;planCandidateWriteSet&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;target: &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;makeBackend&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;options&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;// two Accounts with the same email are the same account&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;writeSet: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;formatVersion: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;sourceId: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;partner-feed&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;target: await &lt;/span&gt;&lt;span&gt;captureCandidateWriteSetTarget&lt;/span&gt;&lt;span&gt;(store)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodes:&lt;/span&gt;&lt;span&gt; [partnerAccount7, partnerGlobex]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges:&lt;/span&gt;&lt;span&gt; []&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;upserts: 2  accounts still 243&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;acct_7 name: keep &quot;Account 7&quot;, feed says &quot;Account 7 Holdings&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;acct_7 plan: keep &quot;team&quot;, feed says &quot;enterprise&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;acct_7 seats: keep 12, feed says 40&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The plan has one new account and one match on &lt;code dir=&quot;auto&quot;&gt;acct_7&lt;/code&gt;, where the feed
disagrees on three properties. The existing values win by default and the
disagreements go on the review, so “enterprise, 40 seats” becomes a line item
for a person to look at instead of a silent overwrite.&lt;/p&gt;
&lt;p&gt;If something unrelated writes to the graph before you apply, applying the
original plan fails:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;StaleMergePlanError: The target revision changed after this merge plan was created; the plan was not applied.  accounts 243&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Re-planning and applying takes the count to 244. The plan is tied to the
target’s revision rather than only the rows it touches, so any write makes it
stale, which is cheap to recover from for a bounded batch. When a person’s
approval has to sit between planning and applying, &lt;a href=&quot;https://typegraph.dev/blog/durable-merge-branches&quot;&gt;durable review&lt;/a&gt;
handles it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;one-consistent-snapshot&quot;&gt;One consistent snapshot&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;At the root store, each &lt;code dir=&quot;auto&quot;&gt;describe()&lt;/code&gt; statement and each &lt;code dir=&quot;auto&quot;&gt;validateStore()&lt;/code&gt; page
is its own read. TypeGraph catches schema changes between those reads but not
data changes, so when a sweep needs one consistent view, 0.64 puts both methods
on the transaction context:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;summary&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;transaction&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;async &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;tx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;const { &lt;/span&gt;&lt;span&gt;statistics&lt;/span&gt;&lt;span&gt; } = await &lt;/span&gt;&lt;span&gt;tx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;describe&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;// ...page through tx.validateStore() exactly as above&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ isolationLevel: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;repeatable_read&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, accessMode: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;read_only&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{ accounts: 244, scanned: 244, violations: [] }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The violations are gone because both repairs landed earlier in the run. Use
&lt;code dir=&quot;auto&quot;&gt;repeatable_read&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;serializable&lt;/code&gt; and consume every page inside the
callback.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;limits&quot;&gt;Limits&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;describe()&lt;/code&gt; covers directly addressable declared properties.&lt;/strong&gt; It
doesn’t guess through &lt;code dir=&quot;auto&quot;&gt;$ref&lt;/code&gt;, unions, arrays, or conditionals.
&lt;code dir=&quot;auto&quot;&gt;validateStore()&lt;/code&gt; is the authority for those.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Both are current-state only.&lt;/strong&gt; There’s no “what did the data look like
last month” analysis.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A guard can only protect what it can name.&lt;/strong&gt; You can’t guard on an
invalid value, so if a person and an agent both repair the same bad field,
a guard on its neighbors won’t tell them apart. The revision-fenced plan is
the alternative there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Planning copies the whole target,&lt;/strong&gt; so its cost scales with the graph.
It’s for bounded review workflows, not a hot path.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;upgrading&quot;&gt;Upgrading&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Writing this post turned up two bugs in 0.68.0 and earlier.
&lt;code dir=&quot;auto&quot;&gt;planCandidateWriteSet()&lt;/code&gt; fails on a target that holds a row with undeclared
properties, like &lt;code dir=&quot;auto&quot;&gt;acct_legacy_3&lt;/code&gt; above
(&lt;a href=&quot;https://github.com/nicia-ai/typegraph/issues/733&quot;&gt;#733&lt;/a&gt;), and
&lt;code dir=&quot;auto&quot;&gt;compareAndSet()&lt;/code&gt; throws a raw Zod error on a kind whose schema has an
object-level &lt;code dir=&quot;auto&quot;&gt;.refine()&lt;/code&gt;
(&lt;a href=&quot;https://github.com/nicia-ai/typegraph/issues/734&quot;&gt;#734&lt;/a&gt;). Both are fixed in
0.68.1 (&lt;a href=&quot;https://github.com/nicia-ai/typegraph/pull/735&quot;&gt;#735&lt;/a&gt;,
&lt;a href=&quot;https://github.com/nicia-ai/typegraph/pull/737&quot;&gt;#737&lt;/a&gt;), which the examples
here assume.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;BulkOperationHookContext[&quot;operation&quot;]&lt;/code&gt; now includes &lt;code dir=&quot;auto&quot;&gt;&quot;compareAndSet&quot;&lt;/code&gt;, so an
exhaustive &lt;code dir=&quot;auto&quot;&gt;switch&lt;/code&gt; over it needs the new case. If you run mixed versions
during a rollout, upgrade every process that writes schema versions to 0.54
before using graph-scoped annotations (also new in 0.54: metadata on the graph
itself, carried through extensions and returned by &lt;code dir=&quot;auto&quot;&gt;describe()&lt;/code&gt;). Older
writers drop fields they don’t recognize.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-extensions#population-statistics-and-stored-data-validation&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;describe()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;validateStore()&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/schemas-stores#compareandsetid-params&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;compareAndSet()&lt;/code&gt;&lt;/a&gt; and
&lt;code dir=&quot;auto&quot;&gt;compareAndSetAbsent&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-merge#constraint-aware-ingestion-branches&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;planCandidateWriteSet()&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Merges That Wait</title><link>https://typegraph.dev/blog/durable-merge-branches/</link><guid isPermaLink="true">https://typegraph.dev/blog/durable-merge-branches/</guid><description>0.56, 0.67 and 0.68 let a reviewed graph merge wait a day for approval, survive a deploy, reopen in another process, and take input from an at-least-once queue without applying anything twice.</description><pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://typegraph.dev/blog/graph-merge&quot;&gt;Graph Merge&lt;/a&gt; plans before it applies: you fork a working
copy with &lt;code dir=&quot;auto&quot;&gt;branch()&lt;/code&gt;, stage changes on it, and &lt;code dir=&quot;auto&quot;&gt;planMerge()&lt;/code&gt; shows you the exact
write set before anything touches the target. That works well as long as
staging, planning and applying all happen in one run of one process.&lt;/p&gt;
&lt;p&gt;The workflows that most want a review step don’t look like that. Say a nightly
job proposes loyalty-point adjustments that a person approves the next
morning, a deploy lands in between, and the thing feeding the branch is a queue
that occasionally delivers the same message twice. An in-memory branch handle
and a plan that goes stale as soon as anything is written can’t cope with any
of that.&lt;/p&gt;
&lt;p&gt;It took three releases to fix, and I think the result is one of the more
unusual things TypeGraph can do. 0.56 made the review itself durable by storing
it as graph data, 0.67 made the working copy durable so a branch can be closed
in one process and reopened in another, and 0.68 made it safe to feed that
branch from an at-least-once queue.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-review-lives-in-the-graph&quot;&gt;The review lives in the graph&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A merge plan is tied to the target’s revision, so if the target changes after
planning, applying the plan fails. That rule is what keeps a stale plan from
clobbering newer data, but it gets in the way if you want the review (the
proposal, the decision, who approved it) stored as graph data next to the
record it concerns, because &lt;em&gt;writing the review down&lt;/em&gt; is itself a write to the
target, and by the time anyone approves the plan it’s stale.&lt;/p&gt;
&lt;p&gt;0.56 resolves this by separating what was reviewed from the plan you
eventually apply. &lt;code dir=&quot;auto&quot;&gt;planCandidateWriteSetReview()&lt;/code&gt; captures the candidate
changes, the plan, the policy, and a baseline of the target as one immutable,
content-digested artifact:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;review&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;planCandidateWriteSetReview&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;target: &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;makeBackend&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;policy: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;manual-acceptance-v1&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;context: { requiredApprovals: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, authorizedReviewers:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;reviewer:maya&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;writeSet: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;formatVersion: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;sourceId: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;catalog-review&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;target: await &lt;/span&gt;&lt;span&gt;captureCandidateWriteSetTarget&lt;/span&gt;&lt;span&gt;(store)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodes:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;          &lt;/span&gt;&lt;/span&gt;&lt;span&gt;kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Item&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;          &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id: proposal&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;          &lt;/span&gt;&lt;/span&gt;&lt;span&gt;properties: { label: proposal&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;label&lt;/span&gt;&lt;span&gt;, status: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;accepted&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges:&lt;/span&gt;&lt;span&gt; []&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;You store it as ordinary graph data, keyed by its own digest, along with the
reviewer’s decision. &lt;code dir=&quot;auto&quot;&gt;Artifact&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Decision&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;evidence&lt;/code&gt; here are ordinary
kinds the example defines itself, so this needs no special schema support:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;artifact&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Artifact&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ content: &lt;/span&gt;&lt;span&gt;JSON&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;stringify&lt;/span&gt;&lt;span&gt;(review)&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ id: &lt;/span&gt;&lt;span&gt;review&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;digest&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;value&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;edges&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;evidence&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(proposal, artifact, { note: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;review&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;decision&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Decision&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;approved: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;reviewDigest: &lt;/span&gt;&lt;span&gt;review&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;digest&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;value&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;reviewer: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;reviewer:maya&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;edges&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;evidence&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(decision, artifact, { note: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;approval&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Applying the original plan at this point fails:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;stale&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;applyMergePlan&lt;/span&gt;&lt;span&gt;(store&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;review&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;plan&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// stale.error is a StaleMergePlanError&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Recording the review and the approval moved the target, so the plan is stale.
This is the part I like: durable review doesn’t get an exemption from the
staleness check, because if recording an approval could quietly un-stale a
plan, the check would mean nothing.&lt;/p&gt;
&lt;p&gt;Instead of reusing the plan, you call &lt;code dir=&quot;auto&quot;&gt;revalidateCandidateWriteSetReview()&lt;/code&gt;,
which reads the stored artifact, re-plans the retained candidate against the
current target, and tells you what it found:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code dir=&quot;auto&quot;&gt;checked.status&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;compatible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nothing that matters moved. You get a fresh &lt;code dir=&quot;auto&quot;&gt;plan&lt;/code&gt; and the original &lt;code dir=&quot;auto&quot;&gt;reviewDigest&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;changed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Policy, options, baseline entities, or plan fields differ from what was reviewed. Get a new review and approval.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;incompatible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Graph id, schema identity, or revision origin don’t match. This approval can’t be used against this target at all.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;In the example only the review and approval records were added, so the result
is &lt;code dir=&quot;auto&quot;&gt;compatible&lt;/code&gt;. I was careful to keep &lt;code dir=&quot;auto&quot;&gt;compatible&lt;/code&gt; meaning only that nothing
relevant moved; it says nothing about whether the caller is allowed to act, so
the example checks both before spending the plan:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (checked&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;compatible&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;A new review and approval are required&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (checked&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;reviewDigest&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;value&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!==&lt;/span&gt;&lt;span&gt; approval&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;reviewDigest&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Approval does not identify the validated review&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;report&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;applyMergePlan&lt;/span&gt;&lt;span&gt;(store&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;checked&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;plan&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Authenticating the stored decision and enforcing &lt;code dir=&quot;auto&quot;&gt;authorizedReviewers&lt;/code&gt; are
still your job. TypeGraph tells you whether the plan is safe to apply and
leaves the question of who may apply it to you.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-working-copy-that-outlives-its-process&quot;&gt;A working copy that outlives its process&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The branch itself was still tied to one process, because a &lt;code dir=&quot;auto&quot;&gt;branch()&lt;/code&gt; result
holds its store and close handle in memory and disposing it deletes the fork.
0.67 adds &lt;code dir=&quot;auto&quot;&gt;branchDurable()&lt;/code&gt;, which forks a working copy that persists and hands
back a small JSON descriptor you can put on a queue. Here’s the loyalty
ledger’s nightly job:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;created&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;branchDurable&lt;/span&gt;&lt;span&gt;(base&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;host));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;staged&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;created&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;branch&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; staged&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Account&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;update&lt;/span&gt;&lt;span&gt;(ada&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, { points: &lt;/span&gt;&lt;span&gt;160&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; staged&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Account&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;({ name: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Grace&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, points: &lt;/span&gt;&lt;span&gt;40&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Releases this process&apos;s connection and writer lease. The working copy stays.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; created&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;branch&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;close&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; queue&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;put&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;JSON&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;stringify&lt;/span&gt;&lt;span&gt;(created&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;descriptor&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The descriptor is the only thing that leaves the process:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;kind&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;sqlite-file-host&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;version&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;graphId&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;loyalty&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;definitionHash&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;ec9dd68d2fbf14e3&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;branchId&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;gluQHM58QmB1LFjZIJEdA&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;base&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;ec9dd68d2fbf14e3#s1&lt;/span&gt;&lt;span&gt;\u0000&lt;/span&gt;&lt;span&gt;revision:kHeqC6EE55ENVL3a_np2R:r1:0000000000000001:2026-09-20T19:16:21.492Z&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;store&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;id&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;35da40a3-1820-4b9f-b9b4-2117e653ded8&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;schemaAnchor&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;version&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;hash&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;ec9dd68d2fbf14e3&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;TypeGraph doesn’t ship a durable host. It defines the contract (a
&lt;code dir=&quot;auto&quot;&gt;DurableWorkingCopyStrategy&lt;/code&gt; with &lt;code dir=&quot;auto&quot;&gt;create&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;seal&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;reopen&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;abort&lt;/code&gt; and
&lt;code dir=&quot;auto&quot;&gt;destroy&lt;/code&gt;), and your host decides where a working copy lives, whether that’s a
directory, a database, or a provider’s branch API. The descriptor’s &lt;code dir=&quot;auto&quot;&gt;store&lt;/code&gt;
field is the host’s opaque locator, and everything else in it belongs to
TypeGraph. The examples here ran against a small file-backed SQLite host,
across separate &lt;code dir=&quot;auto&quot;&gt;node&lt;/code&gt; processes.&lt;/p&gt;
&lt;p&gt;The next day a different process reads the descriptor and reopens the branch,
and what comes back is an ordinary &lt;code dir=&quot;auto&quot;&gt;GraphBranch&lt;/code&gt;, so everything after that is
the merge API you already know:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;descriptor&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;JSON&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;parse&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;queue&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;());&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;branch&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;reopenDurableBranch&lt;/span&gt;&lt;span&gt;(graph&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;descriptor&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;host));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;plan&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;planMerge&lt;/span&gt;&lt;span&gt;(base&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; [branch]));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;report&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;applyDurableMergePlan&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;target: &lt;/span&gt;&lt;span&gt;base&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;branch&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;descriptor&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;strategy: &lt;/span&gt;&lt;span&gt;host&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;plan&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; branch&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;close&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;destroyDurableBranch&lt;/span&gt;&lt;span&gt;(descriptor, host));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;plan: 2 node upserts, 0 conflicts&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;merged: {&quot;nodes&quot;:2,&quot;edges&quot;:0,&quot;identity&quot;:{&quot;asserted&quot;:0,&quot;retracted&quot;:0}}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;base now: Grace=40, Ada=160&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;reopen after destroy refused: true&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;applyDurableMergePlan()&lt;/code&gt; applies the approved plan through the target Store
transaction. The former optional host-native merge hook was retired because a
database merge that commits internally can cross the transaction boundary
that checked the target revision. A host may still use native database
branches for its durable working copies.&lt;/p&gt;
&lt;p&gt;A descriptor is a document your application stored and handed back later, so
TypeGraph treats it as untrusted input. At fork time the host seals the true
origin, and every reopen and destroy is checked against it, so relabeling the
branch id makes the reopen fail:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Durable branch descriptor does not match the working copy the host attested for&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;its store locator: the descriptor&apos;s TypeGraph fences disagree with the origin&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;recorded at fork. This is a tampered, relabeled, or wrong-branch descriptor.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The same check stops you from destroying branch B with branch A’s descriptor,
or reopening with a graph that reuses the id &lt;code dir=&quot;auto&quot;&gt;&quot;loyalty&quot;&lt;/code&gt; but defines
&lt;code dir=&quot;auto&quot;&gt;Account&lt;/code&gt; differently.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-message-that-arrives-twice&quot;&gt;The message that arrives twice&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Suppose the branch is fed from a queue where “award Ada 25 points” can arrive
twice. The award has to apply exactly once, and whoever is downstream (a
notification, an audit log) has to hear about every applied award eventually,
even if the worker dies right after committing.&lt;/p&gt;
&lt;p&gt;What that calls for is a transactional outbox scoped to the working copy, and
0.68 builds one into the durable-branch contract. You hand
&lt;code dir=&quot;auto&quot;&gt;operateDurableBranch()&lt;/code&gt; an idempotency key, a &lt;code dir=&quot;auto&quot;&gt;mutation&lt;/code&gt; describing the
change, and &lt;code dir=&quot;auto&quot;&gt;metadata&lt;/code&gt; to keep as evidence:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;outcome&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;operateDurableBranch&lt;/span&gt;&lt;span&gt;(descriptor&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;host&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;idempotencyKey: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;award-7731&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;metadata: { source: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;orders-queue&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, messageId: &lt;/span&gt;&lt;span&gt;7731&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;mutation: { op: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;award&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, account: &lt;/span&gt;&lt;span&gt;adaId&lt;/span&gt;&lt;span&gt;, points: &lt;/span&gt;&lt;span&gt;25&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;TypeGraph never interprets &lt;code dir=&quot;auto&quot;&gt;mutation&lt;/code&gt;; it digests it together with &lt;code dir=&quot;auto&quot;&gt;metadata&lt;/code&gt;,
hands the host the request, and validates what comes back. The host applies
the change and writes an evidence row in one database transaction, which in
this host is a single SQLite transaction on one connection. To check the
rollback, I made it throw after the graph write and before the evidence insert:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;DurableOperationError | GRAPH_MERGE_OPERATION | Durable operation failed: injected failure after the graph write&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;evidence: undefined&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;accounts: Grace=40, Ada=160&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;There’s no evidence row, and Ada is still at the staged 160.&lt;/p&gt;
&lt;p&gt;In the real run, the worker commits the award (Ada goes from 160 to 185) and
then dies before telling anyone. A fresh process picks up the descriptor, and
the queue redelivers the same message:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;recover pid 76873 | Ada on branch: 185&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;redelivered: replayed | Ada on branch: 185&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;replayed&lt;/code&gt; returns the evidence from the first run and applies nothing, so Ada
stays at 185 instead of 210. If you reuse the key with a different payload,
even just different metadata, the host rejects it without writing:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;changed payload: DurableOperationConflictError | GRAPH_MERGE_OPERATION_CONFLICT&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Ada on branch: 185&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The evidence rows act as the outbox. Each one starts with &lt;code dir=&quot;auto&quot;&gt;delivered: false&lt;/code&gt;,
and you can’t destroy the branch while any are undelivered:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;destroy: DurableEvidenceUndeliveredError | GRAPH_MERGE_OPERATION_UNDELIVERED |&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;refusing to destroy &quot;60367e1c-…&quot;: undelivered operation evidence remains&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;To deliver them, you scan for undelivered rows and mark each one after
publishing it:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;scanDurableOperations&lt;/span&gt;&lt;span&gt;(descriptor&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;host&lt;/span&gt;&lt;span&gt;, { limit: &lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;evidence&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;of&lt;/span&gt;&lt;span&gt; page&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;operations&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (evidence&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;delivered&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;continue&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;publishDownstream&lt;/span&gt;&lt;span&gt;(evidence); &lt;/span&gt;&lt;span&gt;// your outbox consumer&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;markDurableOperationDelivered&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;descriptor,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;host,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;evidence&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;idempotencyKey&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;destroyDurableBranch&lt;/span&gt;&lt;span&gt;(descriptor, host));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A crash at any point in that sequence means, at worst, that a downstream
consumer hears about an award twice; the award itself is never applied twice
or silently dropped.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;limits&quot;&gt;Limits&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;There’s no first-party host and no queue.&lt;/strong&gt; Whether your host’s
transaction is atomic is up to your database. TypeGraph validates what the
host reports back but can’t check your storage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exclusion is the host’s job.&lt;/strong&gt; The example host’s writer lease is a lock
file. When I left one behind, as a killed process would, reopening failed
until it was cleared. A real host wants a lease that expires.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Plan against a quiet branch.&lt;/strong&gt; Feeding operations into a branch while a
reviewer plans against it means planning against a moving target.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delivery is at-least-once.&lt;/strong&gt; Make downstream writes idempotent on the key.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You probably don’t need this for a one-shot job.&lt;/strong&gt; If you stage, plan and
apply in one run, &lt;code dir=&quot;auto&quot;&gt;branch()&lt;/code&gt; is unchanged and simpler.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-merge#durable-candidate-review-in-the-target-graph&quot;&gt;Durable candidate review&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-merge#durable-host-native-branches&quot;&gt;Durable host-native branches&lt;/a&gt;
and &lt;a href=&quot;https://typegraph.dev/graph-merge#atomic-operations-and-immutable-evidence&quot;&gt;atomic operations and evidence&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph/blob/main/packages/typegraph/examples/27-durable-merge-review.ts&quot;&gt;Example 27&lt;/a&gt;:
the durable review, end to end&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Schema Changes That Roll Back With Everything Else</title><link>https://typegraph.dev/blog/schema-evolution-in-one-transaction/</link><guid isPermaLink="true">https://typegraph.dev/blog/schema-evolution-in-one-transaction/</guid><description>A runtime schema change can now commit or roll back in the same transaction as the graph writes that need it, recorded history, and your own SQL.</description><pubDate>Sat, 19 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://typegraph.dev/blog/runtime-schema-evolution&quot;&gt;Runtime Schema Evolution&lt;/a&gt; ended with an agent
adding a kind for retraction notices to a live graph. This post uses a cut-down
version of that: a &lt;code dir=&quot;auto&quot;&gt;Retraction&lt;/code&gt; kind added to a graph of publications.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;store.evolve()&lt;/code&gt; commits a change like that in a transaction of its own and
hands back a new Store. That’s fine when the schema change is the whole job,
but not when your service keeps its own bookkeeping next to the graph, say a
&lt;code dir=&quot;auto&quot;&gt;schema_audit&lt;/code&gt; table recording which schema version each import ran under.&lt;/p&gt;
&lt;p&gt;If you do it in the obvious order, &lt;code dir=&quot;auto&quot;&gt;evolve()&lt;/code&gt; commits first, then a second
transaction writes the retraction row and the audit row. When something
downstream throws in that second transaction, this is what’s left:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;evolve(), then a failed import:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;active schema version  2&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schema_audit rows      0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Retraction node rows   0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The schema is at version 2 but nothing else moved, so there’s a &lt;code dir=&quot;auto&quot;&gt;Retraction&lt;/code&gt;
kind that no ledger entry mentions and no row uses. Nothing is corrupt, but
your bookkeeping and the database now disagree about which schema exists,
because the schema change was the one step that couldn’t join the transaction.
As of 0.62 it can join, and the rest of this post shows how.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;plan-outside-apply-inside&quot;&gt;Plan outside, apply inside&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The setup is a graph with one kind, the extension the agent proposed, and a
Drizzle table for the audit ledger in the same database.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;Publication&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineNode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Publication&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schema: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ doi: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;, title: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;graph&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineGraph&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;trials&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodes: { Publication: { type: &lt;/span&gt;&lt;span&gt;Publication&lt;/span&gt;&lt;span&gt; } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges: {},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;retractions&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineGraphExtension&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodes: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Retraction: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;properties: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;doi: { type: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, minLength: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;reason: { type: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;schemaAudit&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;sqliteTable&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;schema_audit&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id: &lt;/span&gt;&lt;span&gt;integer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;primaryKey&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ autoIncrement: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schemaVersion: &lt;/span&gt;&lt;span&gt;integer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;schema_version&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;notNull&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;recordedAt: &lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;recorded_at&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The change is split into planning and applying. &lt;code dir=&quot;auto&quot;&gt;planEvolution()&lt;/code&gt; runs before
any transaction opens, validates the extension against the active schema
without writing anything, and returns an immutable plan that names the schema
it starts from and the one it produces:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;plan&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;planEvolution&lt;/span&gt;&lt;span&gt;(retractions);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;status&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;change&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;graphId&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;trials&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;baseline&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;version&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;hash&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;89654f9e5c1debfb&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;result&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;version&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;hash&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;aec6b8d50bf21bf8&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;requirements&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ &lt;/span&gt;&lt;span&gt;&quot;kind&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;new-kind&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;entity&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;node&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;kindName&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Retraction&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Doing the planning outside means the slow, fallible part (validation, diffing)
never holds a lock. For ordinary additions like a new kind or an optional
scalar field, applying the plan needs no entity scans and no DDL.&lt;/p&gt;
&lt;p&gt;Applying takes &lt;em&gt;your&lt;/em&gt; transaction. better-sqlite3 is synchronous, so Drizzle’s
&lt;code dir=&quot;auto&quot;&gt;db.transaction()&lt;/code&gt; can’t take an async callback, and a small helper drives
&lt;code dir=&quot;auto&quot;&gt;BEGIN&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;COMMIT&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;ROLLBACK&lt;/code&gt; on the one connection. With node-postgres or libSQL
you’d pass the &lt;code dir=&quot;auto&quot;&gt;nativeTx&lt;/code&gt; from &lt;code dir=&quot;auto&quot;&gt;db.transaction(async (nativeTx) =&gt; …)&lt;/code&gt; instead;
&lt;a href=&quot;https://typegraph.dev/recipes#cross-store-transactions-drizzle--typegraph&quot;&gt;the cross-store transactions recipe&lt;/a&gt;
covers both.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;inTransaction&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;T&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BetterSQLite3Database&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Promise&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;T&lt;/span&gt;&lt;span&gt;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Promise&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;T&lt;/span&gt;&lt;span&gt;&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;sql&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;BEGIN&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;try&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;sql&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;COMMIT&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; result;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;catch&lt;/span&gt;&lt;span&gt; (error) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;sql&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;ROLLBACK&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; error;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;outcome&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;inTransaction&lt;/span&gt;&lt;span&gt;(db&lt;/span&gt;&lt;span&gt;, async &lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; =&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;applied&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;withEvolvedTransaction&lt;/span&gt;&lt;span&gt;(db&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;plan&lt;/span&gt;&lt;span&gt;, async &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;tx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;notices&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;tx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getNodeCollection&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Retraction&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;if &lt;/span&gt;&lt;span&gt;(notices&lt;/span&gt;&lt;span&gt; === &lt;/span&gt;&lt;span&gt;undefined&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; throw &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Retraction kind missing&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;notices&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ doi: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;10.1/a&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, reason: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;fabricated&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;insert&lt;/span&gt;&lt;span&gt;(schemaAudit)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;values&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schemaVersion: &lt;/span&gt;&lt;span&gt;applied&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;receipt&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;schema&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;version&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;recordedAt: &lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt;(applied&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;receipt&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;recorded&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;return &lt;/span&gt;&lt;span&gt;applied&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;current&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;refreshSchema&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;minVersion: &lt;/span&gt;&lt;span&gt;outcome&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;receipt&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;schema&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;version&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Inside the callback, &lt;code dir=&quot;auto&quot;&gt;tx&lt;/code&gt; already sees the new schema: &lt;code dir=&quot;auto&quot;&gt;Retraction&lt;/code&gt; is
writable even though the committed schema doesn’t have it yet. The receipt
carries the exact schema version and hash the transaction produced (and, on a
history-enabled store, the recorded-time anchor), so the audit row stores what
the import actually ran under instead of what the code assumed. Treat the
receipt as provisional until your outer &lt;code dir=&quot;auto&quot;&gt;COMMIT&lt;/code&gt; succeeds; after that,
&lt;code dir=&quot;auto&quot;&gt;refreshSchema()&lt;/code&gt; hands the new schema to the Store you keep around.&lt;/p&gt;
&lt;p&gt;To check the rollback, I threw an error after the ledger insert and ran the
same plan twice, once failing and once clean:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;attempt 1 (fails after the callback):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;receipt: schema v2, recorded r1:0000000000000002:2026-09-20T19:12:56.201Z&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;after rollback:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;active schema version  1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schema_audit rows      0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Retraction node rows   0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;attempt 2 (same plan):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;receipt: schema v2, recorded r1:0000000000000002:2026-09-20T19:12:56.202Z&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;after commit:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;active schema version  2&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schema_audit rows      1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Retraction node rows   1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The failed attempt got as far as a receipt and a ledger row visible inside the
transaction, and none of it survived; when I checked the recorded-history table
directly it had no &lt;code dir=&quot;auto&quot;&gt;Retraction&lt;/code&gt; rows either. The retry reused the same plan, got
the same recorded revision, and committed, so the schema, the rows, the
history, and your own table commit or roll back together.&lt;/p&gt;
&lt;p&gt;If another writer evolves the graph between planning and applying, the apply
throws &lt;code dir=&quot;auto&quot;&gt;StaleVersionError&lt;/code&gt; and the transaction is yours to roll back and
replan.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;no-ops-and-schema-only-checkpoints&quot;&gt;No-ops, and schema-only checkpoints&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A pipeline that runs the same wiring on every deploy will mostly produce no-op
plans. Plan an extension the Store already has and you get
&lt;code dir=&quot;auto&quot;&gt;status: &quot;noop&quot;&lt;/code&gt;, which doesn’t need the exclusive schema lock, so an ordinary
&lt;code dir=&quot;auto&quot;&gt;withRecordedTransaction()&lt;/code&gt; is enough:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;again&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;current&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;planEvolution&lt;/span&gt;&lt;span&gt;(retractions);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (again&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;noop&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;inTransaction&lt;/span&gt;&lt;span&gt;(db, &lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;current&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;withRecordedTransaction&lt;/span&gt;&lt;span&gt;(db, &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;tx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;notices&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;tx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getNodeCollection&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Retraction&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (notices &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;undefined&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Retraction kind missing&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; notices&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;({ doi: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;10.1/b&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, reason: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;duplicate publication&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The opposite case is a schema change that is itself the event worth recording.
On a history-enabled store, &lt;code dir=&quot;auto&quot;&gt;tx.requestRecordedRevision()&lt;/code&gt; puts the change on
the recorded timeline even when no entities change: the receipt reports zero
writes, schema version 2, and a recorded anchor.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;merges-and-reviewed-writes-that-bring-their-own-kinds&quot;&gt;Merges and reviewed writes that bring their own kinds&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;0.61 added &lt;code dir=&quot;auto&quot;&gt;applyMergePlanInTransaction()&lt;/code&gt; for applying an approved merge plan
next to your own SQL. But a merge plan is built against a specific schema, so a
merge that introduces a kind the target doesn’t have yet couldn’t share a
commit with the evolution that adds it. 0.62 closes that gap:
&lt;code dir=&quot;auto&quot;&gt;branchForEvolution()&lt;/code&gt; forks a branch that already has the planned kinds, and
&lt;code dir=&quot;auto&quot;&gt;planMergeForEvolution()&lt;/code&gt; plans against the resulting schema.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;evolutionPlan&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;target&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;planEvolution&lt;/span&gt;&lt;span&gt;(retractions);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;futureBranch&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;branchForEvolution&lt;/span&gt;&lt;span&gt;(target&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;evolutionPlan&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;makeIsolatedBackend)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;try&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; futureBranch&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getNodeCollectionOrThrow&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Retraction&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;({ doi: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;10.1/a&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, reason: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;fabricated&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;mergePlan&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;planMergeForEvolution&lt;/span&gt;&lt;span&gt;(target&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;evolutionPlan&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; [futureBranch])&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;inTransaction&lt;/span&gt;&lt;span&gt;(db, &lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;target&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;withEvolvedTransaction&lt;/span&gt;&lt;span&gt;(db, evolutionPlan, &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;tx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;applyMergePlanInTransaction&lt;/span&gt;&lt;span&gt;(target, tx, mergePlan),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;finally&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; futureBranch&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;close&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A plan built the ordinary way, against the old schema, is rejected inside the
evolved transaction with &lt;code dir=&quot;auto&quot;&gt;MergePlanSchemaMismatchError&lt;/code&gt; before anything is
written.&lt;/p&gt;
&lt;p&gt;0.65 does the same for candidate write sets, the branch-free way to propose
changes as a JSON document a reviewer can read.
&lt;code dir=&quot;auto&quot;&gt;planCandidateWriteSetForEvolution()&lt;/code&gt; plans the agent’s proposed &lt;code dir=&quot;auto&quot;&gt;Retraction&lt;/code&gt;
records against the schema the pending evolution will produce, and nothing is
written until the reviewed plan is applied inside &lt;code dir=&quot;auto&quot;&gt;withEvolvedTransaction()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Because review takes time and the target can move in the meantime, there are
two different stale outcomes. If a write lands &lt;em&gt;while&lt;/em&gt; the plan is being built,
planning returns a &lt;code dir=&quot;auto&quot;&gt;MergePlanningStaleError&lt;/code&gt;, and you recapture the target and
replan. If the target changes after you already hold a finished plan, applying
throws &lt;code dir=&quot;auto&quot;&gt;StaleMergePlanError&lt;/code&gt; inside the outer transaction, and the schema
change rolls back with it. I injected a write after planning to check this, and
the active schema was still at version 1 afterwards.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;limits&quot;&gt;Limits&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Plans stay in one process.&lt;/strong&gt; A plan is an in-memory token that can’t be
serialized or reconstructed, so plan and apply in the same process.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The default adapter only does DML.&lt;/strong&gt; A plan that needs new storage (a
vector slot, identity work) is rejected before the callback runs, unless you
use a privileged adapter configured with
&lt;code dir=&quot;auto&quot;&gt;schemaProvisioning: &quot;transactional&quot;&lt;/code&gt;. Indexes
stay outside too: call &lt;code dir=&quot;auto&quot;&gt;materializeIndexes()&lt;/code&gt; after commit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Only your database’s writes are atomic.&lt;/strong&gt; The ledger row is atomic
with the schema change because they share a connection. A message you
publish to a queue from inside the callback is not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Driver support varies.&lt;/strong&gt; SQLite adoption needs a native connection with an
observable transaction state (better-sqlite3 has one); HTTP-only drivers
can’t adopt schema transactions at all.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Custom &lt;code dir=&quot;auto&quot;&gt;StoreEvolution&lt;/code&gt; implementations need &lt;code dir=&quot;auto&quot;&gt;planEvolution()&lt;/code&gt; and
&lt;code dir=&quot;auto&quot;&gt;refreshSchema()&lt;/code&gt;, and custom adapters must now declare &lt;code dir=&quot;auto&quot;&gt;schemaProvisioning&lt;/code&gt;
explicitly.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-extensions#plan-outside-and-apply-inside-a-caller-owned-transaction&quot;&gt;Plan outside and apply inside a caller-owned transaction&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-merge#merge-after-schema-evolution-in-one-caller-transaction&quot;&gt;Merge after schema evolution in one caller transaction&lt;/a&gt;
and &lt;a href=&quot;https://typegraph.dev/graph-merge#candidate-write-sets-for-a-planned-schema&quot;&gt;candidate write sets for a planned schema&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/backend-setup#adopted-schema-transactions&quot;&gt;Adopted schema transactions&lt;/a&gt;:
per-driver requirements&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Replacing the N+1 Loop With One Statement</title><link>https://typegraph.dev/blog/one-statement-reads/</link><guid isPermaLink="true">https://typegraph.dev/blog/one-statement-reads/</guid><description>How a page listing documents with their newest version, version count, and latest comments went from 33 SQL statements to one, and why batchOnce() throws instead of quietly splitting the work back up.</description><pubDate>Fri, 18 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Most apps have a page like this one: a workspace lists its published
documents, and each row shows the title, how many versions the document has,
the note on the newest version, and the three most recent comments. Every
piece of that is a one-hop read from the document, so the first version anyone
writes is a loop:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;documents&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;whereNode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; document&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;eq&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;published&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;orderBy&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;select&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;execute&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;rows&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;Promise&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;all&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;documents&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;map&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;async &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;versionEdges&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;edges&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;hasVersion&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;findFrom&lt;/span&gt;&lt;span&gt;(document)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;versions&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Version&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getByIds&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;versionEdges&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;map&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;edge&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt; &lt;/span&gt;&lt;span&gt;edge&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;toId&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;commentEdges&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;edges&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;hasComment&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;findFrom&lt;/span&gt;&lt;span&gt;(document)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;comments&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Comment&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getByIds&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;commentEdges&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;map&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;edge&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt; &lt;/span&gt;&lt;span&gt;edge&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;toId&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;// Sort in JavaScript: newest version, version count, three latest comments.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;return &lt;/span&gt;&lt;span&gt;summarize&lt;/span&gt;&lt;span&gt;(document&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;versions&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;comments)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Counted at the driver, that loop issues 33 statements for eight documents,
one for the list and four per document. On in-memory SQLite you’d never
notice, but with the database across a network every one of those is a round
trip, the count grows with the length of the list, and you’re loading every
comment to keep three and every version to keep one.&lt;/p&gt;
&lt;p&gt;This is the N+1, the oldest performance bug in the book, and over releases
0.58 to 0.65 I went after it properly. The same page now costs &lt;strong&gt;one SQL
statement&lt;/strong&gt;, and when TypeGraph can’t do a batch in one statement it throws
before running anything instead of quietly issuing more.&lt;/p&gt;
&lt;p&gt;(Every number here is a statement count, measured by wrapping
better-sqlite3’s &lt;code dir=&quot;auto&quot;&gt;Statement&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;exec&lt;/code&gt; in the script that ran the code. None
of them are wall-clock times. What a round trip costs on your network is
yours to multiply in.)&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;step-one-reads-that-say-what-they-want&quot;&gt;Step one: reads that say what they want&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;store.neighbors()&lt;/code&gt; joins each edge to the node on its far end in a single
statement, and applies ordering and the limit before hydrating anything, so
“the newest version” fetches one row. &lt;code dir=&quot;auto&quot;&gt;store.countNeighbors()&lt;/code&gt; is the matching
count and hydrates nothing at all.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const [&lt;/span&gt;&lt;span&gt;latest&lt;/span&gt;&lt;span&gt;] = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;neighbors&lt;/span&gt;&lt;span&gt;(document&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;hasVersion&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;orderBy: { by: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;node&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, field: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;sequence&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, direction: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;desc&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;limit: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;versions&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;countNeighbors&lt;/span&gt;&lt;span&gt;(document&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;hasVersion&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Two calls per document is 16 statements for the page, which is better than
33 but still grows with the length of the list.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;step-two-batchonce&quot;&gt;Step two: &lt;code dir=&quot;auto&quot;&gt;batchOnce()&lt;/code&gt;&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;store.batchOnce()&lt;/code&gt; is the part I’m happiest with. It takes any number of
independent reads and runs them as exactly one SQL statement: each read
becomes a CTE, and a single JSON envelope carries every result set back, in
input order. The callback can return a runtime-sized array, which means the
loop above translates almost literally:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;results&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;batchOnce&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;read&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;documents&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;flatMap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt;&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;read&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;neighbors&lt;/span&gt;&lt;span&gt;(document, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges: [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;hasVersion&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;orderBy: { by: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;node&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, field: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;sequence&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, direction: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;desc&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;limit: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;read&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;countNeighbors&lt;/span&gt;&lt;span&gt;(document, { edges: [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;hasVersion&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;] }),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;])&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// results[2 * i] is document i&apos;s newest version, results[2 * i + 1] its count.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That takes the page from sixteen statements to one. Inside a transaction,
&lt;code dir=&quot;auto&quot;&gt;tx.batchOnce()&lt;/code&gt; binds to the open connection, so the batch sees writes made
earlier in the same callback and is still one statement.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;it-wont-quietly-fall-back&quot;&gt;It won’t quietly fall back&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A lot of “batch” APIs are really a &lt;code dir=&quot;auto&quot;&gt;for&lt;/code&gt; loop, and you only find out they
issued N queries when a latency graph shows it a month later. &lt;code dir=&quot;auto&quot;&gt;batchOnce()&lt;/code&gt;
has no fallback path, so if it can’t do the job in one statement it throws
before executing anything:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;batchOnce&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;read&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Array&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt;({ length: &lt;/span&gt;&lt;span&gt;501&lt;/span&gt;&lt;span&gt; }, &lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;read&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;countNeighbors&lt;/span&gt;&lt;span&gt;(document, { edges: [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;hasVersion&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;] }),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// ConfigurationError: store.batchOnce() accepts at most 500 reads in one statement.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Five hundred reads run as one statement, and 501 throws without issuing any.
It won’t chunk to fit the backend’s bind-parameter limit either, and reads that
can’t be embedded (edge-collection &lt;code dir=&quot;auto&quot;&gt;batchFind*&lt;/code&gt; calls, for example) are
rejected instead of being run on the side. Since the name promises one
statement, I’d rather it fail loudly than break that promise.&lt;/p&gt;
&lt;p&gt;The older &lt;code dir=&quot;auto&quot;&gt;store.batch()&lt;/code&gt;, by contrast, runs its queries one after another. It
always did, and the docs now say so plainly because people were putting it in
hot paths expecting a single round trip.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;step-three-shape-it-in-sql&quot;&gt;Step three: shape it in SQL&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A batch of &lt;code dir=&quot;auto&quot;&gt;neighbors()&lt;/code&gt; calls still has one read per parent, so eight
documents means sixteen reads inside the statement, and the 500-read ceiling
works out to 250 documents. When the question is “for every document in this
result”, a relation answers it with a fixed number of reads however long the
list is.
0.61 added &lt;code dir=&quot;auto&quot;&gt;project()&lt;/code&gt;, derived relations, and aggregates; 0.63 added
&lt;code dir=&quot;auto&quot;&gt;topPerPartition()&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;published&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; =&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;whereNode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; document&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;eq&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;published&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;versions&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;published&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;traverse&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;hasVersion&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;link&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;to&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Version&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;version&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;project&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;fields&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;documentId: fields&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;versionId: fields&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;version&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;sequence: fields&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;version&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sequence&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;note: fields&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;version&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;note&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;asRelation&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;versionCounts&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;versions&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;groupBy&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;columns&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; [columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;documentId&lt;/span&gt;&lt;span&gt;])&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;aggregate&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;columns&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;documentId: columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;documentId&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;versions: expr&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;count&lt;/span&gt;&lt;span&gt;(columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;versionId&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;latestVersions&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;versions&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;topPerPartition&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;partitionBy&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;columns&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt;&lt;/span&gt;&lt;span&gt; [columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;documentId&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;orderBy&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;columns&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt;&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ expression: columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sequence&lt;/span&gt;&lt;span&gt;, direction: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;desc&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ expression: columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;versionId&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;limit: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;topPerPartition()&lt;/code&gt; uses &lt;code dir=&quot;auto&quot;&gt;ROW_NUMBER()&lt;/code&gt;, so ties don’t widen the limit. Both
orderings end in an id because the API can’t know your ordering is unique,
and a repeatable winner needs a tiebreaker. The comments are the same shape
with a limit of three, plus an ordered collection that folds the winners into
one array per document:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;recentComments&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;published&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;traverse&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;hasComment&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;link&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;to&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Comment&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;comment&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;project&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;fields&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;documentId: fields&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;commentId: fields&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;comment&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;body: fields&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;comment&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;body&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;postedAt: fields&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;comment&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;postedAt&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;asRelation&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;topPerPartition&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;partitionBy&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;columns&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; [columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;documentId&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;orderBy&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;columns&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ expression: columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;postedAt&lt;/span&gt;&lt;span&gt;, direction: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;desc&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, nulls: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;last&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ expression: columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;commentId&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;limit: &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;})&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;groupBy&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;columns&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; [columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;documentId&lt;/span&gt;&lt;span&gt;])&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;aggregate&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;columns&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;documentId: columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;documentId&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;recent: expr&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;collect&lt;/span&gt;&lt;span&gt;(columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;body&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;orderBy: [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ expression: columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;postedAt&lt;/span&gt;&lt;span&gt;, direction: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;desc&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, nulls: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;last&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ expression: columns&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;commentId&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Relations are batch members like any other read, so the whole page, titles
included, is one statement:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;titles&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;published&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;orderBy&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;select&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ({ id: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, title: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt; }));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const [&lt;/span&gt;&lt;span&gt;documents&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;counts&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;latest&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;comments&lt;/span&gt;&lt;span&gt;] = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;batchOnce&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; =&gt;&lt;/span&gt;&lt;span&gt; [titles, versionCounts, latestVersions, recentComments]&lt;/span&gt;&lt;span&gt; as const,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Joined on &lt;code dir=&quot;auto&quot;&gt;documentId&lt;/code&gt;, a row comes out as:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{ id: &quot;1BMs3-6PEJYXtjB9BcrsU&quot;, versionCount: 3, latest: &quot;v3 of doc 2&quot;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;recent: [&quot;comment 5 on doc 2&quot;, &quot;comment 4 on doc 2&quot;, &quot;comment 3 on doc 2&quot;] }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The script asserts that these eight rows are identical to what the loop
produced. Here’s how the versions of the page compare:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page for eight documents&lt;/th&gt;
&lt;th&gt;Statements&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Loop of &lt;code dir=&quot;auto&quot;&gt;findFrom()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;getByIds()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loop of &lt;code dir=&quot;auto&quot;&gt;neighbors()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;countNeighbors()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;batchOnce()&lt;/code&gt; of the &lt;code dir=&quot;auto&quot;&gt;neighbors()&lt;/code&gt; reads&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;batchOnce()&lt;/code&gt; of four relations&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;neighbors()&lt;/code&gt; fits when you already hold a handful of sources, and relations
fit when you want “every parent in this result”. &lt;code dir=&quot;auto&quot;&gt;topPerPartition()&lt;/code&gt; needs
window functions
and ordered &lt;code dir=&quot;auto&quot;&gt;expr.collect()&lt;/code&gt; needs ordered aggregates; a backend without them
throws a typed error before executing.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-rest-of-the-set-shaped-toolkit&quot;&gt;The rest of the set-shaped toolkit&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A few smaller pieces from the same stretch, all aimed at the same habit of
doing one thing per row:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;bulkFindFrom()&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;bulkFindTo()&lt;/code&gt;&lt;/strong&gt; are &lt;code dir=&quot;auto&quot;&gt;findFrom()&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;findTo()&lt;/code&gt; for a
whole page of endpoints. Index &lt;code dir=&quot;auto&quot;&gt;i&lt;/code&gt; of the result holds the edges of input
&lt;code dir=&quot;auto&quot;&gt;i&lt;/code&gt;, an endpoint with no edges gets an empty array, and &lt;code dir=&quot;auto&quot;&gt;limitPerInput&lt;/code&gt;
caps each endpoint’s fan-out. Fifty people’s jobs cost one statement per
endpoint kind (split only when the bind-parameter budget requires it)
instead of fifty.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;people&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;find&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ limit: &lt;/span&gt;&lt;span&gt;50&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;jobsPerPerson&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;edges&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;worksAt&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;bulkFindFrom&lt;/span&gt;&lt;span&gt;(people);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;store.bulkFindEdgesTo()&lt;/code&gt;&lt;/strong&gt; does the inbound direction across several
node and edge kinds at once. Twelve documents and two edge kinds was one
statement; calling &lt;code dir=&quot;auto&quot;&gt;findTo()&lt;/code&gt; per kind per document was 24.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;updateWhere()&lt;/code&gt;&lt;/strong&gt; is a set-based, transactional update that returns how
many rows changed. The selector is mandatory (&lt;code dir=&quot;auto&quot;&gt;where&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;exists&lt;/code&gt;, a
candidate query, or an explicit &lt;code dir=&quot;auto&quot;&gt;all: true&lt;/code&gt;), so you can’t wipe out a whole
kind by forgetting a filter:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;updateWhere&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;patch: { active: &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;where&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;person&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt; &lt;/span&gt;&lt;span&gt;person&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;lastSeen&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;lt&lt;/span&gt;&lt;span&gt;(cutoff)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;exists:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edgeKind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;worksAt&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;direction: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;out&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;relatedKind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Company&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;whereRelated&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;company&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;company&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;field&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;eq&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;closed&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// { affectedCount: number }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;in()&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;notIn()&lt;/code&gt; take list parameters&lt;/strong&gt;, so &lt;code dir=&quot;auto&quot;&gt;field.in(param(&quot;ids&quot;))&lt;/code&gt;
binds a runtime-sized list in a prepared query instead of forcing you to
rebuild the query per call.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Subgraphs got per-edge-kind windows&lt;/strong&gt; (keep only the newest N edges of a
noisy kind), and &lt;code dir=&quot;auto&quot;&gt;subgraph()&lt;/code&gt; works inside &lt;code dir=&quot;auto&quot;&gt;batchOnce()&lt;/code&gt;. Five roots
awaited in a loop cost 10 statements on SQLite; batched, one.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Mixed-kind cursor pages&lt;/strong&gt;: a query can start from several kinds
(&lt;code dir=&quot;auto&quot;&gt;.from([&quot;Person&quot;, &quot;Team&quot;], &quot;entity&quot;)&lt;/code&gt;), and a &lt;code dir=&quot;auto&quot;&gt;.page()&lt;/code&gt; can sit in a batch
next to unrelated reads, so a directory of people and teams can be read as
one ordered stream at one statement per page.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;executeChecked(version)&lt;/code&gt;&lt;/strong&gt; folds the “has another isolate changed the
schema?” probe into the read itself, for serverless deployments that cache
the schema per isolate. Probing and then reading took 2 statements, and the
checked read takes 1.
A moved schema throws &lt;code dir=&quot;auto&quot;&gt;SchemaChangedError&lt;/code&gt;, even when the query would have
matched no rows.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;what-one-statement-doesnt-buy-you&quot;&gt;What one statement doesn’t buy you&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It saves round trips, but the database does the same work.&lt;/strong&gt; Every member
of a batch still runs its own plan. For one large closure on Postgres, the
direct &lt;code dir=&quot;auto&quot;&gt;subgraph()&lt;/code&gt; can beat the batched form, and &lt;code dir=&quot;auto&quot;&gt;topPerPartition()&lt;/code&gt;
bounds the rows returned, not necessarily the rows scanned.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Results are materialized.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;batchOnce()&lt;/code&gt; returns JSON envelopes, doesn’t
stream, and has no byte cap. Bound your reads with limits and projections.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The counts are SQLite driver counts.&lt;/strong&gt; They show the shape of the
improvement, and your actual latency depends on your network.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/schemas-stores#storebatchoncebuildreads-options&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;store.batchOnce()&lt;/code&gt;&lt;/a&gt;:
the full contract, including what can and can’t be embedded&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/queries/relations#top-n-per-parent&quot;&gt;Top-N per parent&lt;/a&gt; and
&lt;a href=&quot;https://typegraph.dev/queries/relations#ordered-collections&quot;&gt;Ordered collections&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/schemas-stores#updatewhereparams&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;updateWhere()&lt;/code&gt;&lt;/a&gt; and
&lt;a href=&quot;https://typegraph.dev/schemas-stores#storebulkfindedgestoparams-options&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;bulkFindEdgesTo()&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Tracking Which Records Are the Same Person</title><link>https://typegraph.dev/blog/operational-identity/</link><guid isPermaLink="true">https://typegraph.dev/blog/operational-identity/</guid><description>store.identity records claims that two nodes are the same real-world thing, or provably aren&apos;t, keeps them queryable at any point in time after they&apos;re retracted, and has the database reject a set of claims that contradicts itself.</description><pubDate>Sat, 12 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Say a bank’s KYC onboarding writes a &lt;code dir=&quot;auto&quot;&gt;Person&lt;/code&gt; node for each customer, keyed by
the bank’s customer id, and its fraud pipeline writes a &lt;code dir=&quot;auto&quot;&gt;CaseSubject&lt;/code&gt; for
everyone named in an investigation. When the person under investigation is a
walk-in customer, the case system reuses the bank’s id, so one human being
ends up as two nodes of different kinds, and nothing in the graph says they’re
the same person.&lt;/p&gt;
&lt;p&gt;Most graph libraries handle “these are the same thing” at the type level, and
TypeGraph used to as well. The &lt;code dir=&quot;auto&quot;&gt;sameAs&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;differentFrom&lt;/code&gt; ontology factories
related &lt;em&gt;kinds&lt;/em&gt; rather than rows, and &lt;code dir=&quot;auto&quot;&gt;differentFrom&lt;/code&gt; never checked anything
about actual data, which is of no use when a compliance team needs to say that
this particular customer is that particular case subject. Both factories are
deprecated now.&lt;/p&gt;
&lt;p&gt;Their replacement, &lt;code dir=&quot;auto&quot;&gt;store.identity&lt;/code&gt;, is one of my favorite things in the
library. It’s a ledger of claims about individual nodes, either that two of
them are the same entity or that they’re provably not. You can query it at any
point in time and retract a claim without deleting it, the claims carry
through traversals and merges, and the database itself won’t commit a set of
claims that contradicts itself.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;folding-on-a-shared-id&quot;&gt;Folding on a shared id&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Identity is opt-in per graph. Turning it on also decides what happens when
nodes of different kinds share an id:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;graph&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineGraph&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;compliance&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodes: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Person: { type: &lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;CaseSubject: { type: &lt;/span&gt;&lt;span&gt;CaseSubject&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Organization: { type: &lt;/span&gt;&lt;span&gt;Organization&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges: {},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ontology:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;disjointWith&lt;/span&gt;&lt;span&gt;(Person, Organization)]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;identity: { sameIdAcrossKinds: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;fold&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;With &lt;code dir=&quot;auto&quot;&gt;&quot;fold&quot;&lt;/code&gt;, live nodes of different kinds that share an id are the same
entity automatically. (&lt;code dir=&quot;auto&quot;&gt;&quot;ignore&quot;&lt;/code&gt; gives you the ledger without that implicit
join, for graphs where a shared id is a coincidence.) The two systems above
never have to coordinate:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;person&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ name: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Alex Rivera&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ id: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;cust-8842&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;subject&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;CaseSubject&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ note: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Named in fraud case FC-2201&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ id: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;cust-8842&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;membersOf&lt;/span&gt;&lt;span&gt;(person);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// [{ kind: &quot;CaseSubject&quot;, id: &quot;cust-8842&quot; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;//  { kind: &quot;Person&quot;, id: &quot;cust-8842&quot; }]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A fold starts when the node actually exists, not at its &lt;code dir=&quot;auto&quot;&gt;validFrom&lt;/code&gt;. A node
created today with a backdated validity window shows up in historical reads,
but it doesn’t retroactively fold anything in the past.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;saying-it-explicitly&quot;&gt;Saying it explicitly&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Most matches don’t come with a shared id. Here an investigator links a
walk-in customer to an actor in an unrelated case, under two ids that have
nothing in common:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;walkIn&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ name: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;J. Alvarez&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ id: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;person-119&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;caseActor&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;CaseSubject&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ note: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Signed the wire authorization in case FC-2214&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ id: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;case-77&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;linked&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;assertSame&lt;/span&gt;&lt;span&gt;(walkIn&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;caseActor);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// { action: &quot;created&quot;, assertion: { id, relation: &quot;same&quot;, a, b, validFrom } }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;assertSame&lt;/span&gt;&lt;span&gt;(walkIn, caseActor);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// { action: &quot;existing&quot;, assertion: &amp;#x3C;same assertion&gt; } — idempotent&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Asserting the same thing twice gives you the existing assertion back, so a
job that re-runs after a crash doesn’t need to know whether it got that far
last time. There are bulk forms (&lt;code dir=&quot;auto&quot;&gt;bulkAssertSame&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;bulkAssertDifferent&lt;/code&gt;) that
return one result per input pair, in order.&lt;/p&gt;
&lt;p&gt;Claims are also checked against the ontology before anything is stored.
Since &lt;code dir=&quot;auto&quot;&gt;Person&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;disjointWith&lt;/code&gt; &lt;code dir=&quot;auto&quot;&gt;Organization&lt;/code&gt;, folding the walk-in onto a
company fails:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;assertSame&lt;/span&gt;&lt;span&gt;(walkIn, acmeFreight);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// throws IdentityContradictionError&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// details: { operation: &quot;assertSame&quot;, reason: &quot;disjoint-kinds&quot;, a, b }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Claims can also be bounded in time. A merged account that was later split, or
a case subject that was only correct for the window an investigation covered,
gets a half-open validity window:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;assertSame&lt;/span&gt;&lt;span&gt;(alice, legacyAlice, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;validFrom: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;2020-01-01T00:00:00.000Z&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;validTo: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;2022-01-01T00:00:00.000Z&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Both endpoints have to exist for the whole window, and contradictions are
checked across every overlapping stretch of time, including through chains of
&lt;code dir=&quot;auto&quot;&gt;same&lt;/code&gt; claims.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;changing-your-mind&quot;&gt;Changing your mind&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Suppose forensic review later shows that &lt;code dir=&quot;auto&quot;&gt;person-119&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;case-77&lt;/code&gt; are two
different people who happened to share a wire-authorization signature. You
want to record that, but the ledger currently says they’re the same entity,
and it won’t hold both claims at once:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;assertDifferent&lt;/span&gt;&lt;span&gt;(walkIn, caseActor);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// throws IdentityContradictionError&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// details: { operation: &quot;assertDifferent&quot;, reason: &quot;same-class&quot;, a, b }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;You retract the old claim first, explicitly:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;ended&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;retractAssertion&lt;/span&gt;&lt;span&gt;(linked&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;assertion&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// ended.validTo: &quot;2026-08-21T18:03:11.442Z&quot; — the fold ends, timestamped&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;assertDifferent&lt;/span&gt;&lt;span&gt;(walkIn, caseActor);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// { action: &quot;created&quot;, ... } — now recorded as provably different&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;areSame&lt;/span&gt;&lt;span&gt;(walkIn, caseActor); &lt;/span&gt;&lt;span&gt;// false&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;areDifferent&lt;/span&gt;&lt;span&gt;(walkIn, caseActor); &lt;/span&gt;&lt;span&gt;// true&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Nothing was deleted: the retracted assertion and its &lt;code dir=&quot;auto&quot;&gt;validTo&lt;/code&gt; are still
readable, and a query at a time before the retraction still sees one entity,
so if an auditor asks why these two records were treated as one customer in
August, you can answer them.&lt;/p&gt;
&lt;p&gt;Soft-deleting a node ends its current assertions too, and records the node as
the reason (&lt;code dir=&quot;auto&quot;&gt;endedBy&lt;/code&gt;), so a later reader can tell &lt;em&gt;why&lt;/em&gt; a claim ended.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-backstop-in-the-database&quot;&gt;A backstop in the database&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Everything so far is application code deciding whether a write is allowed,
and application code can be wrong. &lt;code dir=&quot;auto&quot;&gt;assertSame&lt;/code&gt; checks against state it just
read, so a bug somewhere else that writes identity rows directly could still
commit the contradiction the API rejects.&lt;/p&gt;
&lt;p&gt;So identity also keeps a table of which identity classes are held apart by a
&lt;code dir=&quot;auto&quot;&gt;different&lt;/code&gt; claim, with a database &lt;code dir=&quot;auto&quot;&gt;CHECK&lt;/code&gt; constraint on it. When a
transaction merges two classes, it rewrites those rows in the same batch. If
two classes that are supposed to be separate get merged anyway, the rewrite
violates the constraint and the database aborts the transaction:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// IdentitySeparationViolationError&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// details: { graphId, enforcedBy: &quot;database&quot;, classKey,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;//            assertionId, a, b }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The API catches contradictions first and gives you a useful error, and the
constraint is there for the case where the API has a bug.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;traversals-that-understand-it&quot;&gt;Traversals that understand it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Queries can expand through identity classes, per hop, and it’s off by
default:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;results&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;person&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;traverse&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;authored&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;edge&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, { includeIdentityMembers: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt; })&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;to&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;select&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ({ edge: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;edge&lt;/span&gt;&lt;span&gt;, document: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt; }))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;execute&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That hop follows &lt;code dir=&quot;auto&quot;&gt;authored&lt;/code&gt; edges from the person &lt;em&gt;and&lt;/em&gt; from every other node
in their identity class, and returns the real edge and target rows with
duplicates removed.&lt;/p&gt;
&lt;p&gt;At the current time, each hop looks classes up through an index on the
maintained closure, so the cost tracks the rows you start from and the size of
their classes, not how many classes the graph holds. Measured on SQLite, with
each &lt;code dir=&quot;auto&quot;&gt;Person&lt;/code&gt; folded to a &lt;code dir=&quot;auto&quot;&gt;Company&lt;/code&gt; and an &lt;code dir=&quot;auto&quot;&gt;Alias&lt;/code&gt; sharing its id, before and
after the closure became index-seekable:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;source rows&lt;/th&gt;
&lt;th&gt;fan-out&lt;/th&gt;
&lt;th&gt;matching edges&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;67 ms&lt;/td&gt;
&lt;td&gt;6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;1077 ms&lt;/td&gt;
&lt;td&gt;9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;4616 ms&lt;/td&gt;
&lt;td&gt;19 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8000&lt;/td&gt;
&lt;td&gt;8611 ms&lt;/td&gt;
&lt;td&gt;13 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;51,602 ms&lt;/td&gt;
&lt;td&gt;77 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That closure only describes the present, which is worth planning around. A
hop at a &lt;em&gt;historical&lt;/em&gt; time has to rebuild classes from the assertion ledger
for the whole graph, once per statement, even if you only asked about one
node, so queries about the present are much cheaper than queries about the
past.&lt;/p&gt;
&lt;p&gt;Identity claims also carry through interchange and graph merge. Exports carry
assertions (current ones by default, ended ones too if you ask for an archival
export), and graph merge treats identity as part of what it diffs. Two branches
that make opposing claims about the same pair, or that contradict each other
through a chain of &lt;code dir=&quot;auto&quot;&gt;same&lt;/code&gt; claims neither wrote alone, fail at plan time with
&lt;code dir=&quot;auto&quot;&gt;IdentityMergeConflictError&lt;/code&gt;, and the merge checks again inside its own
transaction before committing.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;staging-the-duplicate-you-came-to-resolve&quot;&gt;Staging the duplicate you came to resolve&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Ingestion is where identity gets messiest. Say a provider feed arrives with a
patient carrying MRN &lt;code dir=&quot;auto&quot;&gt;MRN-4471&lt;/code&gt; and the graph already has a &lt;code dir=&quot;auto&quot;&gt;Patient&lt;/code&gt; with
that MRN. That’s expected, since deciding whether the two are the same person
is why the ingestion pass exists, but &lt;code dir=&quot;auto&quot;&gt;Patient&lt;/code&gt; declares &lt;code dir=&quot;auto&quot;&gt;unique(&quot;mrn&quot;)&lt;/code&gt;, so
the second row can’t be written. If you stage it on an ordinary &lt;code dir=&quot;auto&quot;&gt;branch()&lt;/code&gt;,
staging fails at the duplicate before entity resolution has seen anything.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;ingestionBranch()&lt;/code&gt; makes a working copy that defers node uniqueness and
nothing else, so schema validation, endpoint checks, disjointness and edge
cardinality all still apply while staging. The usual answer to this problem is
a “skip validation” flag on the import, which I didn’t want, because it lets
every other kind of bad row in alongside the duplicates you meant to allow.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;incoming&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;unwrap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;ingestionBranch&lt;/span&gt;&lt;span&gt;(base&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;makeBackend&lt;/span&gt;&lt;span&gt;, { id: &lt;/span&gt;&lt;span&gt;asBranchId&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;provider-a&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;imported&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;importGraph&lt;/span&gt;&lt;span&gt;(incoming&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;providerDocument&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;onConflict: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;error&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;onUnknownProperty: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;error&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;imported&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;success&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Provider import was rejected&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;alias&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;incoming&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Patient&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getById&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;asNodeId&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;incoming-patient&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (alias &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;undefined&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Imported patient was not found&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// `canonicalPatient` was read from the base before forking.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; incoming&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;assertSame&lt;/span&gt;&lt;span&gt;(canonicalPatient, alias);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The duplicate MRN and the claim that explains it now sit on the same branch
and reach merge planning together. The handle only exposes the assertion
methods, without identity reads, retractions, transactions, or access to the
underlying store, so it can’t be used to get around the deferred constraint.&lt;/p&gt;
&lt;p&gt;At merge time the original uniqueness rule applies again. &lt;code dir=&quot;auto&quot;&gt;applyMergePlan()&lt;/code&gt;
checks uniqueness against the entire resolved write set inside the target
transaction, so a valid key handoff or swap goes through as one set (a
row-by-row check would see two owners in the middle and reject it). If
resolution still leaves two live owners of one MRN, the merge fails with
&lt;code dir=&quot;auto&quot;&gt;MergeConstraintConflictError&lt;/code&gt; and writes nothing, so the deferral only gives
you room to review the duplicate before merging.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-costs&quot;&gt;What it costs&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A graph without identity enabled pays nothing: no identity SQL, locks, or
closure work. With it on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One writer per graph for identity-affecting writes on Postgres.&lt;/strong&gt; They
serialize on a per-graph advisory lock. Other graphs and all reads are
unaffected.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;First-time enablement is heavy.&lt;/strong&gt; It briefly takes a &lt;code dir=&quot;auto&quot;&gt;SHARE&lt;/code&gt; lock on the
shared nodes table, which blocks writes for every graph in that database,
and loads the whole graph to build the initial closure. Do it in a quiet
window.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Switching &lt;code dir=&quot;auto&quot;&gt;fold&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;ignore&lt;/code&gt; is a breaking schema change,&lt;/strong&gt; because it
changes every &lt;code dir=&quot;auto&quot;&gt;areSame&lt;/code&gt; answer on existing data. It needs an explicit
migration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It needs real transactions.&lt;/strong&gt; The bundled SQLite and Postgres drivers
have them. Cloudflare D1 and &lt;code dir=&quot;auto&quot;&gt;drizzle-orm/neon-http&lt;/code&gt; don’t, and reject an
identity-enabled graph at construction.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;store.identity&lt;/code&gt; records and propagates the identity claims &lt;em&gt;you&lt;/em&gt; make, so
deciding that two records are the same person is still up to you or your
entity-resolution pass. What it adds is a durable record of those decisions,
including when each was made and when it stopped being true.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/identity&quot;&gt;Operational Identity&lt;/a&gt;: the full guide, including migrating off
&lt;code dir=&quot;auto&quot;&gt;sameAs&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;differentFrom&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-merge#constraint-aware-ingestion-branches&quot;&gt;Constraint-aware ingestion branches&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-merge&quot;&gt;Graph Merge&lt;/a&gt;: branch, resolve, and merge identity back&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Bring Your Own Database</title><link>https://typegraph.dev/blog/bring-your-own-database/</link><guid isPermaLink="true">https://typegraph.dev/blog/bring-your-own-database/</guid><description>How TypeGraph stopped assuming Drizzle, a pg pool, and an engine that locks like Postgres: Drizzle became an optional peer, backends now declare what they can&apos;t do, and engine profiles let you adapt a bundled backend to a different engine.</description><pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The first versions of TypeGraph depended on Drizzle and assumed the database
underneath was either a &lt;code dir=&quot;auto&quot;&gt;pg&lt;/code&gt; pool or &lt;code dir=&quot;auto&quot;&gt;better-sqlite3&lt;/code&gt;. That held up until
people started running it on Cloudflare D1, inside Durable Objects, over
Neon’s HTTP driver, in PGlite, and on engines that speak the Postgres wire
protocol but handle locking very differently from Postgres.&lt;/p&gt;
&lt;p&gt;Each of those broke an assumption somewhere, usually as a SQL error from deep
inside a query the engine couldn’t run, and over three releases (0.38, 0.51,
and 0.57) I’ve been removing those assumptions. This post covers all three:
how Drizzle became optional, how a backend can now declare what it can’t do
before a query fails, and how an engine that locks differently can describe
that to TypeGraph.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;drizzle-is-an-adapter-now&quot;&gt;Drizzle is an adapter now&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Before 0.38, every &lt;code dir=&quot;auto&quot;&gt;Store&amp;#x3C;G&gt;&lt;/code&gt; carried Drizzle’s types whether your code
touched them or not. A strict TypeScript project that only ever called
&lt;code dir=&quot;auto&quot;&gt;store.nodes.Person.create(...)&lt;/code&gt; still had to resolve Drizzle’s dialect
declarations to typecheck, which is a lot of ORM to pull into your type
checking just to create a node.&lt;/p&gt;
&lt;p&gt;Now the portable &lt;code dir=&quot;auto&quot;&gt;Store&amp;#x3C;G&gt;&lt;/code&gt; has no Drizzle in it. The managed factories own
the connection and hand you a complete store:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { createLocalSqliteStore } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;@nicia-ai/typegraph/sqlite/local&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;createLocalSqliteStore&lt;/span&gt;&lt;span&gt;(graph&lt;/span&gt;&lt;span&gt;, { path: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;./graph.db&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;alice&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ name: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Alice&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;created: LBPSjEoGPqI0P3C6kaJ5M Alice&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;capabilities.execution.interactiveTransactions: true&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;createLocalPgliteStore&lt;/code&gt; does the same for Postgres-in-WASM. The full graph
API is there, &lt;code dir=&quot;auto&quot;&gt;store.transaction(...)&lt;/code&gt; included.&lt;/p&gt;
&lt;p&gt;When you do want to write your own tables on the same connection, you opt in
with &lt;code dir=&quot;auto&quot;&gt;createAdapterStore&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;tx.sql&lt;/code&gt; hands you the native transaction:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;transaction&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;tx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; tx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;update&lt;/span&gt;&lt;span&gt;(documentId, props);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (tx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sqlAvailability&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;available&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;Native transaction unavailable: &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;tx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sqlAvailability&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; tx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sql&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;insert&lt;/span&gt;&lt;span&gt;(documentVersions)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;values&lt;/span&gt;&lt;span&gt;(versionRow);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;sqlAvailability&lt;/code&gt; is a discriminant because raw SQL is sometimes off on
purpose: on a history-enabled store, writing around TypeGraph would skip
recorded-time capture, so &lt;code dir=&quot;auto&quot;&gt;tx.sql&lt;/code&gt; isn’t available there.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;drizzle-is-now-an-optional-dependency&quot;&gt;Drizzle is now an optional dependency&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;In 0.51 &lt;code dir=&quot;auto&quot;&gt;drizzle-orm&lt;/code&gt; became an optional peer dependency, and ten
entrypoints don’t need it installed at all: the root, &lt;code dir=&quot;auto&quot;&gt;backend&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;core&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;schema&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;indexes&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;graph-extension&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;interchange&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;profiler&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;graph-merge&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;provenance&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That list is enforced by tests: a fixture imports all ten with &lt;code dir=&quot;auto&quot;&gt;drizzle-orm&lt;/code&gt;
missing from &lt;code dir=&quot;auto&quot;&gt;node_modules&lt;/code&gt;, and source and build-output checks fail if an
import ever drags it back in. The last three routes to Drizzle
(recorded-time migration DDL, a claim comparison, and some removal-statement
builders) moved to portable code with golden tests pinning byte-identical SQL
on both dialects.&lt;/p&gt;
&lt;p&gt;If you use a managed store or a &lt;code dir=&quot;auto&quot;&gt;/adapters/drizzle/...&lt;/code&gt; entrypoint you still
need Drizzle, and if your package manager skips optional peers you’ll need to
install it yourself. The managed factories tell you so with a typed error
that includes the &lt;code dir=&quot;auto&quot;&gt;npm install&lt;/code&gt; command, instead of a module-resolution stack
trace.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;backends-say-what-they-cant-do&quot;&gt;Backends say what they can’t do&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Removing the dependency was the easier part. The harder problem is that
TypeGraph emits SQL some engines can’t run, and it used to find that out in
production, halfway through a query.&lt;/p&gt;
&lt;p&gt;Recursive CTEs are the clearest case, since variable-length traversals,
subgraph extraction, and a few identity reads depend on them. An engine
without them can now say so:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;capabilities&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Partial&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;BackendCapabilities&lt;/span&gt;&lt;span&gt;&gt; = {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;recursiveTraversal: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;supported: &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;reason: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;engine has no WITH RECURSIVE / equivalent&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;With that declared, the operations that need recursion throw a
&lt;code dir=&quot;auto&quot;&gt;ConfigurationError&lt;/code&gt; naming the operation, instead of sending the engine SQL
it can’t parse. &lt;code dir=&quot;auto&quot;&gt;weightedShortestPath&lt;/code&gt; falls back to walking the path one hop
at a time, which returns the same answer in more round trips. Leaving the
capability out means it’s supported, so every existing custom backend keeps
working as it did.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;how-your-engine-keeps-writers-apart&quot;&gt;How your engine keeps writers apart&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Some writes (Operational Identity, and the recorded-time clock behind
&lt;code dir=&quot;auto&quot;&gt;history&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;revisionTracking&lt;/code&gt;) need exactly one writer per graph at a
time. TypeGraph used to pick the lock by checking which dialect it was
talking to, which went badly if your engine said “postgres” but had no
advisory locks.&lt;/p&gt;
&lt;p&gt;Now the backend declares how it keeps writers apart, which for the two bundled
engines is one line each:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// PostgreSQL&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;writeFence: { mechanism: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;advisory&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, drain: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;table-lock&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// SQLite&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;writeFence: { mechanism: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;engine-serialized&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A custom backend that hosts identity or recorded history without declaring
one is rejected at construction, and the error message prints the line to add
for your dialect.&lt;/p&gt;
&lt;p&gt;0.57 added two more mechanisms. &lt;code dir=&quot;auto&quot;&gt;caller-serialized&lt;/code&gt; is your promise that
nothing else writes to the database; TypeGraph enforces the in-process half
and trusts you with the rest. &lt;code dir=&quot;auto&quot;&gt;row&lt;/code&gt; is for engines with no advisory locks at
all: TypeGraph takes the lock by upserting a row in its own
&lt;code dir=&quot;auto&quot;&gt;typegraph_fences&lt;/code&gt; table. If your engine settles write conflicts at commit
time rather than blocking, declare &lt;code dir=&quot;auto&quot;&gt;conflict: &quot;commit-time&quot;&lt;/code&gt; and TypeGraph
retries its own transactions as a whole unit when they lose, up to three
attempts.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;engine-profiles&quot;&gt;Engine profiles&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The piece I’m happiest about is that 0.57 made the two bundled backends
&lt;em&gt;data&lt;/em&gt;. &lt;code dir=&quot;auto&quot;&gt;createPostgresBackend&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;createSqliteBackend&lt;/code&gt; are now the same
function, &lt;code dir=&quot;auto&quot;&gt;createSqlBackend&lt;/code&gt;, applied to an engine profile: the dialect, how
it executes, how it provisions, and what it declares. That means you can take
a bundled profile, change what’s different about your engine, and get a real
backend:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;buildSqliteEngineProfile,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;createSqlBackend,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;deriveEngineProfile,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;@nicia-ai/typegraph/adapters/drizzle/engine&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { createLocalSqliteBackend } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;@nicia-ai/typegraph/adapters/drizzle/sqlite/local&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const { &lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt; } = &lt;/span&gt;&lt;span&gt;createLocalSqliteBackend&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;base&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;buildSqliteEngineProfile&lt;/span&gt;&lt;span&gt;(db);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;derived&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;deriveEngineProfile&lt;/span&gt;&lt;span&gt;(base&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;declaredCapabilities: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;base&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;declaredCapabilities&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;writeFence: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;mechanism: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;row&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;drain: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;quiescent&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;conflict: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;commit-time&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;backend&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;createSqlBackend&lt;/span&gt;&lt;span&gt;(derived);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;console&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;log&lt;/span&gt;&lt;span&gt;(backend&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;capabilities&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;execution&lt;/span&gt;&lt;span&gt;?.&lt;/span&gt;&lt;span&gt;unitOfWork&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// &quot;optimistic-retry&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;(It’s SQLite here only because that’s what runs on a laptop.) You never set
&lt;code dir=&quot;auto&quot;&gt;unitOfWork&lt;/code&gt; yourself; TypeGraph works it out from what the engine declared
rather than guessing from the engine’s name.&lt;/p&gt;
&lt;p&gt;Derivation is narrow on purpose. You can override declared capabilities, the
lock SQL, and a handful of runtime hooks, but not &lt;code dir=&quot;auto&quot;&gt;dialect&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;execution&lt;/code&gt;,
because the bundled builders capture those in more than one place, and I’d
rather throw at construction than hand you a backend that’s half one engine
and half another.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;one-error-for-you-lost-the-race&quot;&gt;One error for “you lost the race”&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A Postgres serialization failure or deadlock used to surface as whatever the
driver felt like throwing. Now every conflict comes back as
&lt;code dir=&quot;auto&quot;&gt;TransactionConflictError&lt;/code&gt;, with the driver error as &lt;code dir=&quot;auto&quot;&gt;cause&lt;/code&gt;, and
&lt;code dir=&quot;auto&quot;&gt;store.transaction()&lt;/code&gt; can retry for you:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// SQLite never raises 40001; this stands in for what PostgreSQL would.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;serializationFailure&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; Object&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;assign&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;could not serialize access&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;code: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;40001&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;let &lt;/span&gt;&lt;span&gt;attempts&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;transaction&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;tx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;attempts &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; tx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Account&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;({ owner: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;ada&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, balance: &lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (attempts &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;serializationFailure&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ retry: { attempts: &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt; } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;console&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;log&lt;/span&gt;&lt;span&gt;(attempts, (&lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Account&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;find&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// 3 1 — two rolled-back attempts left nothing behind&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The callback reruns from the top, so it has to be safe to run more than once:
read inside it, don’t reuse values from outside it, and don’t cause side
effects that escape the transaction.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;branches-your-host-can-copy&quot;&gt;Branches your host can copy&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;branch()&lt;/code&gt; normally copies a graph by streaming it into a fresh database.
0.57 also adds &lt;code dir=&quot;auto&quot;&gt;forkedWorkingCopyStrategy&lt;/code&gt;, which hands the copy to whatever
your host is good at, such as a file copy, &lt;code dir=&quot;auto&quot;&gt;CREATE DATABASE ... TEMPLATE&lt;/code&gt;, or
a provider’s branch API. Because the fork is a physical copy of the database,
it keeps things a streamed copy can’t, including recorded history, so a forked
branch can answer &lt;code dir=&quot;auto&quot;&gt;asOfRecorded&lt;/code&gt; queries from before the fork was taken.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-isnt-there-yet&quot;&gt;What isn’t there yet&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;You can’t build an engine from scratch yet. &lt;code dir=&quot;auto&quot;&gt;deriveEngineProfile&lt;/code&gt; adapts one
of the two bundled profiles, and building a profile from nothing needs pieces
that aren’t exported. No third engine has been run through this in production
yet either: the commit-time retry path is tested by injecting conflicts into
real SQLite and PGlite transactions rather than against an engine that
produces them on its own. If you have one, I’d like to hear how it goes.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;upgrading&quot;&gt;Upgrading&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;From 0.37, the Drizzle-specific entrypoints moved under &lt;code dir=&quot;auto&quot;&gt;/adapters/drizzle&lt;/code&gt;
and the old paths are gone rather than aliased:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;0.37&lt;/th&gt;
&lt;th&gt;0.38 and later&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;/sqlite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;/adapters/drizzle/sqlite&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;/sqlite/local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;/adapters/drizzle/sqlite/local&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;/sqlite/libsql&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;/adapters/drizzle/sqlite/libsql&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;/postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;/adapters/drizzle/postgres&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;/postgres/pglite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;/adapters/drizzle/postgres/pglite&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;/sqlite/local&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;/postgres/pglite&lt;/code&gt; now mean the managed store factories.
If you read &lt;code dir=&quot;auto&quot;&gt;tx.sql&lt;/code&gt;, switch to &lt;code dir=&quot;auto&quot;&gt;createAdapterStore&lt;/code&gt; /
&lt;code dir=&quot;auto&quot;&gt;createAdapterStoreWithSchema&lt;/code&gt;; if you don’t, nothing changes.&lt;/p&gt;
&lt;p&gt;Custom backend authors: &lt;code dir=&quot;auto&quot;&gt;capabilities.pessimisticLocks&lt;/code&gt; is now
&lt;code dir=&quot;auto&quot;&gt;capabilities.writeFence&lt;/code&gt;, and conflicts should be matched as
&lt;code dir=&quot;auto&quot;&gt;TransactionConflictError&lt;/code&gt; rather than by SQLSTATE. The
&lt;a href=&quot;https://typegraph.dev/changelog#0570&quot;&gt;0.57.0 changelog&lt;/a&gt; has the full list.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/backend-setup#drizzle-free-entrypoints&quot;&gt;Backend Setup&lt;/a&gt;: entrypoints,
capabilities, and the parity matrix&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/backend-setup#write-fence-declaration-writefence&quot;&gt;Write fence declaration&lt;/a&gt;:
every mechanism and what each error means&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/backend-authoring&quot;&gt;Authoring an engine profile&lt;/a&gt;: the derivable fields and
worked examples&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Letting an Edge&apos;s Targets Depend on Its Source</title><link>https://typegraph.dev/blog/source-dependent-edges/</link><guid isPermaLink="true">https://typegraph.dev/blog/source-dependent-edges/</guid><description>Since 0.55 an edge kind can allow different targets for each source kind, so an assignedTo edge can link employees to departments and students to courses while the other two combinations fail at compile time and at runtime.</description><pubDate>Fri, 04 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This schema looks reasonable, but it allows more than you probably meant:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;assignedTo&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineEdge&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;assignedTo&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;from:&lt;/span&gt;&lt;span&gt; [Employee, Student]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;to:&lt;/span&gt;&lt;span&gt; [Department, Course]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;You want employees assigned to departments and students assigned to courses,
but array-valued &lt;code dir=&quot;auto&quot;&gt;from&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;to&lt;/code&gt; declare the Cartesian product, so every
source may point at every target and both
&lt;code dir=&quot;auto&quot;&gt;store.edges.assignedTo.create(student, department)&lt;/code&gt; and
&lt;code dir=&quot;auto&quot;&gt;create(employee, course)&lt;/code&gt; succeed. Until 0.55 the only way to rule those two
combinations out was to split the edge into two kinds and query them
separately.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;say-which-source-gets-which-targets&quot;&gt;Say which source gets which targets&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;to&lt;/code&gt; can now be a map from source kind to its own allowed targets:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;assignedTo&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineEdge&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;assignedTo&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;from:&lt;/span&gt;&lt;span&gt; [Employee, Student]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;to: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Employee:&lt;/span&gt;&lt;span&gt; [Department]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Student:&lt;/span&gt;&lt;span&gt; [Course]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;graph&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineGraph&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;assignments&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodes: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Employee: { type: &lt;/span&gt;&lt;span&gt;Employee&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Student: { type: &lt;/span&gt;&lt;span&gt;Student&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Department: { type: &lt;/span&gt;&lt;span&gt;Department&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Course: { type: &lt;/span&gt;&lt;span&gt;Course&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges: { &lt;/span&gt;&lt;span&gt;assignedTo&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;create(employee, department)&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;create(student, course)&lt;/code&gt; still work, and
the other two combinations fail before anything is written:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;EndpointPairError: assignedTo: undeclared endpoint pair&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ edgeKind: &quot;assignedTo&quot;, endpoint: &quot;pair&quot;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;fromKind: &quot;Employee&quot;, toKind: &quot;Course&quot;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;allowedPairs: [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ from: &quot;Employee&quot;, to: &quot;Department&quot; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ from: &quot;Student&quot;, to: &quot;Course&quot; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;] }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;In typed code you won’t get that far, because the collection’s &lt;code dir=&quot;auto&quot;&gt;create()&lt;/code&gt;
signature narrows per source and handing it an employee and a course is a
compile error. The runtime check covers the paths a type checker can’t see, such
as dynamic collections, bulk writes, and imports. An endpoint kind that isn’t in
&lt;code dir=&quot;auto&quot;&gt;from&lt;/code&gt; at all still throws the existing &lt;code dir=&quot;auto&quot;&gt;EndpointError&lt;/code&gt;; &lt;code dir=&quot;auto&quot;&gt;EndpointPairError&lt;/code&gt; is
specifically for two kinds that are each valid but were never declared together.&lt;/p&gt;
&lt;p&gt;The map itself is checked when you call &lt;code dir=&quot;auto&quot;&gt;defineEdge()&lt;/code&gt;: every kind in &lt;code dir=&quot;auto&quot;&gt;from&lt;/code&gt;
needs an entry, extra keys aren’t allowed, and no entry may be empty. A
mistake throws &lt;code dir=&quot;auto&quot;&gt;ConfigurationError&lt;/code&gt; at that point rather than turning up later
as a confusing write failure.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;narrowing-never-widening&quot;&gt;Narrowing, never widening&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;An edge’s map is its outer bound, and a graph or a runtime
&lt;a href=&quot;https://typegraph.dev/blog/runtime-schema-evolution&quot;&gt;graph extension&lt;/a&gt; can register a narrower
version:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;edges: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;assignedTo: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;type: assignedTo,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;from: [Employee],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;to: { Employee: [Department] }, &lt;/span&gt;&lt;span&gt;// this graph has no students&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A registration can’t add a pair the edge never declared. That includes the
easy mistake of registering the old array form, &lt;code dir=&quot;auto&quot;&gt;to: [Department, Course]&lt;/code&gt;,
against an edge whose map only allows the correlated pairs, which would
quietly re-admit the cross-pairs the map is there to forbid, so it’s rejected.&lt;/p&gt;
&lt;p&gt;Subclasses are checked against the declared pairs. With &lt;code dir=&quot;auto&quot;&gt;SubTask subClassOf Task&lt;/code&gt; and an edge that allows &lt;code dir=&quot;auto&quot;&gt;Task: [Task]&lt;/code&gt;, every mix of &lt;code dir=&quot;auto&quot;&gt;Task&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;SubTask&lt;/code&gt;
works, but a &lt;code dir=&quot;auto&quot;&gt;SubTask&lt;/code&gt; can’t borrow a target that only some other source kind is
allowed.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;bulk-writes-are-all-or-nothing&quot;&gt;Bulk writes are all or nothing&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;edges&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;assignedTo&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;bulkCreate&lt;/span&gt;&lt;span&gt;([&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ from: employeeA, to: departmentA }, &lt;/span&gt;&lt;span&gt;// valid&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ from: employeeB, to: courseA }, &lt;/span&gt;&lt;span&gt;// undeclared pair&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;]);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// throws EndpointPairError; store.edges.assignedTo.count() is still 0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A single undeclared pair rejects the whole batch, because committing the
valid half and quietly dropping the rest would leave you guessing which rows
made it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;pairs-are-stored-in-the-schema&quot;&gt;Pairs are stored in the schema&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The pairs are stored in the serialized schema, and declaring them in a
different key order doesn’t change the schema hash. Replacing an array &lt;code dir=&quot;auto&quot;&gt;to&lt;/code&gt;
with a map removes pairs even though it removes no kinds, so it counts as a
breaking change and follows the same migration rules as any other breaking
edge change. See
&lt;a href=&quot;https://typegraph.dev/schema-management#endpoint-pair-changes&quot;&gt;endpoint pair changes&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/core-concepts#source-dependent-targets&quot;&gt;Source-Dependent Targets&lt;/a&gt;: the
full reference&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/errors#endpointpairerror&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;EndpointPairError&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/schema-management#endpoint-pair-changes&quot;&gt;Endpoint pair changes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>One Round Trip Per Write</title><link>https://typegraph.dev/blog/serverless-write-fusion/</link><guid isPermaLink="true">https://typegraph.dev/blog/serverless-write-fusion/</guid><description>A single TypeGraph write used to cost five or six sequential round trips, which adds up to about a quarter of a second from an edge worker to managed Postgres. 0.52 and 0.53 fold most writes into one request.</description><pubDate>Tue, 01 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I turned on statement logging, turned off prepared statements, and created one
node. This is what went over the wire:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;begin&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;SELECT ... schema-version fence probe&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;SELECT ... duplicate/endpoint check&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;INSERT ... (the write)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;commit&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s five round trips to write one row. On a pooled connection sitting next
to the database you’d never notice, but a lot of people run TypeGraph from
edge functions against managed Postgres, where a round trip is more like 45ms
and each of those statements pays it in full, so the row takes roughly a
quarter of a second to exist.&lt;/p&gt;
&lt;p&gt;When I captured a first-run provisioning flow for one graph, it issued 97
statements, and about 40 of them, spread across just seven writes, were
schema-version probes, duplicate checks, and &lt;code dir=&quot;auto&quot;&gt;begin&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;commit&lt;/code&gt; rather than
actual writes.&lt;/p&gt;
&lt;p&gt;That was &lt;a href=&quot;https://github.com/nicia-ai/typegraph/issues/533&quot;&gt;issue #533&lt;/a&gt;, and
closing it took two releases. The short version: an eligible write and every
check it depends on now go to the database together, as one request.&lt;/p&gt;
&lt;p&gt;A note on the numbers: everything below is a count of requests crossing the
transport, which is what I measured. Latency figures are that count times an
assumed 45ms round trip, not wall-clock benchmarks.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;checks-and-write-in-one-statement&quot;&gt;Checks and write, in one statement&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The old path was a sequence of reads, each deciding whether the write could
proceed: whether the schema version still matched, whether the row was a
duplicate, whether the edge’s endpoints existed, and whether the cardinality
constraint held. Each of those was another round trip, and the whole sequence
was wrapped in a transaction so nothing could change between the checks and
the insert.&lt;/p&gt;
&lt;p&gt;In 0.52, for the common shape on Postgres (schema-managed, generated ID, no
history or identity tracking), all of those checks and the insert compile into
a single statement, and the database evaluates the conditions and performs
the write atomically.&lt;/p&gt;
&lt;p&gt;That takes &lt;strong&gt;five or six sequential requests down to one&lt;/strong&gt;, which at 45ms
removes roughly 180–225ms of waiting from every such write. On Neon’s WebSocket
driver, a typical get-or-create that misses drops from five requests to one.&lt;/p&gt;
&lt;p&gt;When a write needs something a single statement can’t carry (history capture,
a caller-owned transaction, call-level &lt;code dir=&quot;auto&quot;&gt;matchOn&lt;/code&gt;), it takes the ordinary
transactional path, which is still there underneath for everything the fast
path doesn’t cover.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;batches-on-drivers-without-transactions&quot;&gt;Batches on drivers without transactions&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The bigger win is on Neon HTTP, Cloudflare D1, and libSQL, which don’t give
you an interactive transaction at all, only one-shot atomic batches. Before
0.52, a bulk write on one of them ran statement by statement, so it was slow
and a partial failure left partial data behind.&lt;/p&gt;
&lt;p&gt;Now TypeGraph compiles eligible bulk writes into a precompiled program that
those drivers execute as a single atomic batch. &lt;code dir=&quot;auto&quot;&gt;nodes.bulkInsert()&lt;/code&gt; becomes
one request, edge batches validate their endpoints inside the write instead of
reading candidate endpoints first, and batches with a cardinality constraint
(&lt;code dir=&quot;auto&quot;&gt;one&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;unique&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;oneActive&lt;/code&gt;) carry the constraint checks in the same batch.
Counting actual &lt;code dir=&quot;auto&quot;&gt;execute()&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;batch()&lt;/code&gt; calls on libSQL:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bulk edge write&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unconstrained&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With a durable match identity&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With a cardinality constraint&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Since it’s all one batch, a conflict anywhere rolls the whole thing back. I
wanted proof that the conflict check actually did something, so I wrote its
regression test by deleting the conflict clause, watching duplicates land,
and then putting the clause back and watching the same input get rejected.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;053-the-writes-that-have-to-look-first&quot;&gt;0.53: the writes that have to look first&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;0.52 fused the writes that create things, which left the ones that need to
know what’s already there: updates, upserts, and deletes that have to release
the uniqueness claims their row held. 0.53 moved those onto the same programs.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Single-row &lt;code dir=&quot;auto&quot;&gt;update()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;delete()&lt;/code&gt;&lt;/strong&gt; are one read plus one guarded
write, rather than a transaction around both: 60–67% fewer requests.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;bulkUpsertById()&lt;/code&gt;&lt;/strong&gt; is two requests: one batched read, one atomic write.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;bulkReplaceById()&lt;/code&gt;&lt;/strong&gt; is new. Each item is a complete document, so there’s
nothing to read first. Creates, replacements, resurrections of deleted
rows, uniqueness claims, and fulltext and vector index updates all go in one
request.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Large D1 upserts stay atomic.&lt;/strong&gt; D1 allows 100 bind parameters per
statement, so the program splits into many statements inside one atomic
batch. That raised the ceiling from 17 nodes and 6 edges to 512 nodes and
187 edges per call. Anything bigger falls back to the regular path instead
of building an unbounded request.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Postgres transaction sessions run the same programs&lt;/strong&gt; through a
savepoint, so a rejected program doesn’t poison the transaction around it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There’s one trade-off to know about: the fused &lt;code dir=&quot;auto&quot;&gt;update()&lt;/code&gt; is optimistic. It
doesn’t hold a lock between its read and its write, so if the row moved
underneath it, it re-reads and tries again, up to four times, and then throws
&lt;code dir=&quot;auto&quot;&gt;DatabaseOperationError&lt;/code&gt;. Under sustained contention on the same row, that can
fail a write that a transaction-capable backend used to serialize for you. I
think that’s the right trade for most workloads, but not for something like a
hot counter.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;letting-the-database-decide-match-identities&quot;&gt;Letting the database decide: match identities&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Fusing an edge get-or-create needed something the database could arbitrate on
its own, without a lock or an in-process cache: a real unique constraint. So
0.52 also added &lt;strong&gt;durable edge match identities&lt;/strong&gt;. An edge can declare a named
set of fields that identify it:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;worksAt&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineEdge&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;worksAt&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schema: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ role: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;edges: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;worksAt: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;type: worksAt,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;from: [Person],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;to: [Company],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cardinality: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;many&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;matchIdentity: { name: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;employment&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, fields: [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;role&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;] },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;TypeGraph stores that key on every edge row and backs it with a unique
index, on both SQLite and Postgres. Once that exists,
&lt;code dir=&quot;auto&quot;&gt;getOrCreateByEndpoints&lt;/code&gt; no longer has to read, decide, write, and hope
nothing raced, because it’s a single conditional insert the database resolves
atomically, which is what makes the one-request path safe on a stateless edge
worker with nothing in memory to lean on.&lt;/p&gt;
&lt;p&gt;Changing a match identity is a breaking schema change and is rejected while
the edge kind has rows. Call-level &lt;code dir=&quot;auto&quot;&gt;matchOn&lt;/code&gt; still works for matching you
don’t want in the schema, through the regular transactional path.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;upgrading&quot;&gt;Upgrading&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If you use a bundled backend (&lt;code dir=&quot;auto&quot;&gt;createLocalSqliteBackend&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;createPostgresBackend&lt;/code&gt;, or one of the serverless factories), you don’t need to
change any code.&lt;/p&gt;
&lt;p&gt;There are two things to know. An existing or externally provisioned database has
to be opened once through &lt;code dir=&quot;auto&quot;&gt;createStoreWithSchema()&lt;/code&gt; (or get TypeGraph’s
generated base-schema migration) before the zero-DDL verified-store paths will
run; until then they fail early with &lt;code dir=&quot;auto&quot;&gt;BaseSchemaMigrationError&lt;/code&gt;. And
&lt;code dir=&quot;auto&quot;&gt;store.transaction()&lt;/code&gt; now throws on a backend without interactive transactions
instead of quietly running your callback without one.&lt;/p&gt;
&lt;p&gt;If you maintain a custom backend, &lt;code dir=&quot;auto&quot;&gt;GraphBackend.commands&lt;/code&gt; is now required and
a few capability flags changed shape. The
&lt;a href=&quot;https://typegraph.dev/backend-setup#authoritative-command-sessions&quot;&gt;authoritative command sessions&lt;/a&gt;
section has the migration.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/performance/overview#batch-write-patterns&quot;&gt;Batch write patterns&lt;/a&gt; and
&lt;a href=&quot;https://typegraph.dev/performance/overview#remote-edge-convergence&quot;&gt;remote edge convergence&lt;/a&gt;:
which writes are eligible, and the measured counts&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/backend-setup#upgrading-deployment-wide-base-storage&quot;&gt;Upgrading deployment-wide base storage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/changelog#0530&quot;&gt;Changelog&lt;/a&gt; for 0.53.0, and &lt;a href=&quot;https://typegraph.dev/changelog#0520&quot;&gt;0.52.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Five Bugs That Didn&apos;t Crash</title><link>https://typegraph.dev/blog/quietly-wrong/</link><guid isPermaLink="true">https://typegraph.dev/blog/quietly-wrong/</guid><description>Five TypeGraph bugs from the last few months that returned plausible answers instead of throwing, how each was fixed, and the tools for checking whether your data was affected.</description><pubDate>Fri, 14 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A bug that crashes is the easy kind, because it tells you where it is. The ones
I worry about in a database library are the ones that return a
reasonable-looking answer (zero rows, a successful write, a clean startup)
while the data is wrong, so you find out weeks later or not at all.&lt;/p&gt;
&lt;p&gt;Over the last stretch of releases (0.44 through 0.50, plus one older fix) I’ve
fixed five of those in TypeGraph. None of them warrants a post of its own, but
together they show how I want the library to behave, and one of them you
could hit just by doing what TypeGraph’s own error message told you to do.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;rows-that-exist-at-no-point-in-time&quot;&gt;Rows that exist at no point in time&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A valid-time window is half-open, so &lt;code dir=&quot;auto&quot;&gt;asOf(t)&lt;/code&gt; returns a row when
&lt;code dir=&quot;auto&quot;&gt;valid_from &amp;#x3C;= t &amp;#x3C; valid_to&lt;/code&gt;. If you backfilled a record you knew had already
ended, you’d pass a &lt;code dir=&quot;auto&quot;&gt;validTo&lt;/code&gt; in the past and no &lt;code dir=&quot;auto&quot;&gt;validFrom&lt;/code&gt;, and the write
stamped its own instant as &lt;code dir=&quot;auto&quot;&gt;validFrom&lt;/code&gt;, which put the start after the end. A
window that runs backwards contains no &lt;code dir=&quot;auto&quot;&gt;t&lt;/code&gt; at all, so the row was stored,
counted, and exported, but no temporal read could ever return it.&lt;/p&gt;
&lt;p&gt;Since 0.48, a write that creates a row (or resets its window) with a &lt;code dir=&quot;auto&quot;&gt;validTo&lt;/code&gt;
at or before its own instant and no &lt;code dir=&quot;auto&quot;&gt;validFrom&lt;/code&gt; stores no lower bound instead,
so the row reads as “ended at T, start unknown”, which is what you meant. A
future &lt;code dir=&quot;auto&quot;&gt;validTo&lt;/code&gt; behaves as before. Custom backends can call the same helper
the built-in ones use, &lt;code dir=&quot;auto&quot;&gt;resolveStampedValidityLowerBound&lt;/code&gt;, so the rule lives
in one place.&lt;/p&gt;
&lt;p&gt;While I was in there, 0.47 added the thing people were faking with
delete-and-recreate: &lt;code dir=&quot;auto&quot;&gt;clearValidTo: true&lt;/code&gt; reopens a window you closed too
early, on the same row, with &lt;code dir=&quot;auto&quot;&gt;oneActive&lt;/code&gt; edges rechecked because reopening can
create a second active edge.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Employment&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;updateById&lt;/span&gt;&lt;span&gt;(employmentId, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;patch: { department: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Research&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;clearValidTo: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;upgrading-doesnt-repair-old-rows-on-purpose&quot;&gt;Upgrading doesn’t repair old rows, on purpose&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Older versions could store inverted windows, and upgrading leaves them alone.
I think that’s the right call: an upgrade that made invisible rows start
appearing in historical queries would change your reports and replays without
telling you which rows had moved, which is its own quiet bug.&lt;/p&gt;
&lt;p&gt;So the repair is an explicit operator action, with a dry run:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { repairInvertedValidityWindows } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;@nicia-ai/typegraph&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;report&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;repairInvertedValidityWindows&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;backend: &lt;/span&gt;&lt;span&gt;anyBackend&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;relations: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;live-and-recorded&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;mode: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;report&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// report.counts.recordedNodes === undefined means NOT SCANNED, never &quot;clean&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;mode: &quot;apply&quot;&lt;/code&gt; rewrites what &lt;code dir=&quot;auto&quot;&gt;report&lt;/code&gt; counted. Stop writers first, pass the raw
backend on a history-enabled store, and repair &lt;code dir=&quot;auto&quot;&gt;&quot;live-and-recorded&quot;&lt;/code&gt; unless you
have a reason not to, because fixing only the live rows leaves the recorded
history carrying the same backwards window and &lt;code dir=&quot;auto&quot;&gt;asOfRecorded&lt;/code&gt; will keep serving
it. The &lt;a href=&quot;https://typegraph.dev/schema-management#repairing-inverted-validity-windows&quot;&gt;runbook&lt;/a&gt; has
the details.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;constraints-that-importgraph-walked-straight-past&quot;&gt;Constraints that &lt;code dir=&quot;auto&quot;&gt;importGraph&lt;/code&gt; walked straight past&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;TypeGraph lets you declare hierarchy-wide uniqueness
(&lt;code dir=&quot;auto&quot;&gt;scope: &quot;kindWithSubClasses&quot;&lt;/code&gt;), &lt;code dir=&quot;auto&quot;&gt;disjointWith(Person, Organization)&lt;/code&gt;, and
edge cardinality (&lt;code dir=&quot;auto&quot;&gt;one&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;unique&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;oneActive&lt;/code&gt;). Plain per-kind uniqueness was
always backed by a real database key, but these three were enforced by a
writer that took the per-graph write lock, checked, and then wrote.&lt;/p&gt;
&lt;p&gt;That works only as long as every writer takes the lock, and &lt;code dir=&quot;auto&quot;&gt;importGraph&lt;/code&gt;
didn’t. It also skipped the disjointness and cardinality checks entirely, so
an import could commit a &lt;code dir=&quot;auto&quot;&gt;Person&lt;/code&gt; and an &lt;code dir=&quot;auto&quot;&gt;Organization&lt;/code&gt; with the same id, or
three edges on a &lt;code dir=&quot;auto&quot;&gt;cardinality: &quot;one&quot;&lt;/code&gt; relationship, and report success. Import
is also the path most likely to be carrying data you didn’t write yourself.&lt;/p&gt;
&lt;p&gt;As of 0.50, each of those constraints is also backed by a reservation row
whose primary key admits exactly one owner. Taking the constraint means
winning that insert, so a writer holding no lock still loses the race it
should lose. Import now enforces all three and reports rejected rows in its
&lt;code dir=&quot;auto&quot;&gt;errors&lt;/code&gt; like any other violation. Every store and import write also goes
through a single write pipeline now. Import had drifted because it had its own
hand-built write path, and nobody noticed which rules it was missing.&lt;/p&gt;
&lt;p&gt;There are two caveats. This only covers writes that go through TypeGraph, so
raw SQL inserting into the node or edge tables skips the reservation. And
databases created before 0.50 need the new edge-claims table, which the normal
bootstrap or the generated migration SQL provides.&lt;/p&gt;
&lt;p&gt;As with validity windows, the fix prevents new violations and leaves old ones
where they are. To find those:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;violation&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;of&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;verifyConstraintFences&lt;/span&gt;&lt;span&gt;()) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;console&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;warn&lt;/span&gt;&lt;span&gt;(violation&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;family&lt;/span&gt;&lt;span&gt;, violation&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;target&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;axis&lt;/span&gt;&lt;span&gt;, violation&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;target&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;key&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The audit reads the nodes and edges themselves rather than the new
reservation tables, because a database written before those tables existed
has no reservations in it, and an audit that only looked there would report
zero violations on exactly the data you’re worried about. It writes and
repairs nothing, since picking which of two conflicting rows survives destroys
data either way, and that decision should be yours.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-search-index-that-said-it-was-fine&quot;&gt;A search index that said it was fine&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Fulltext and vector search need their own tables. TypeGraph creates them on
first use and writes a marker row saying so, and from then on it trusted the
marker without checking that the tables were still there.&lt;/p&gt;
&lt;p&gt;When one went missing out of band (a partial restore, a migration that
recreated a schema, or an edge runtime that lost a file), the store opened
cleanly and then failed on the first search with a raw driver error about a
missing relation. This mostly showed up on Cloudflare Durable Objects, where
&lt;a href=&quot;https://typegraph.dev/blog/infinite-graph-databases&quot;&gt;one SQLite database per tenant&lt;/a&gt; means
thousands of small databases that nobody is watching individually.&lt;/p&gt;
&lt;p&gt;That error is now a &lt;code dir=&quot;auto&quot;&gt;ContributionUnavailableError&lt;/code&gt; with
&lt;code dir=&quot;auto&quot;&gt;state: &quot;physical-storage-missing&quot;&lt;/code&gt; and rebuild guidance. The check only runs
on the error path, so healthy stores don’t pay for it. There’s also a
three-step ladder for when search is broken, from cheapest to most drastic:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Writes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Probe&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;store.probeContributions()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nothing. Safe on a replica&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repair&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;store.repairContributions()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Marker rows, &lt;code dir=&quot;auto&quot;&gt;IF NOT EXISTS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rebuild&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;store.rebuildContribution(&quot;fulltext&quot;)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deletes and refills this graph’s rows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Start at the top and stop when the probe says &lt;code dir=&quot;auto&quot;&gt;ready&lt;/code&gt;. Rebuild is the only
fix when the table exists in a shape the current code no longer produces, and
it needs a maintenance window. Vector storage can’t be rebuilt, because the
embeddings exist only in the table a rebuild would drop, so TypeGraph won’t
drop it. The
&lt;a href=&quot;https://typegraph.dev/troubleshooting#contribution-health-probe-repair-rebuild&quot;&gt;troubleshooting guide&lt;/a&gt;
walks through each state.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-migration-that-deleted-your-runtime-kinds&quot;&gt;A migration that deleted your runtime kinds&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This one stings. &lt;a href=&quot;https://typegraph.dev/blog/runtime-schema-evolution&quot;&gt;Runtime schema evolution&lt;/a&gt;
lets an agent add node and edge kinds with &lt;code dir=&quot;auto&quot;&gt;store.evolve()&lt;/code&gt;, and those kinds
live in the stored schema rather than in your TypeScript graph definition.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;migrateSchema()&lt;/code&gt; committed whatever graph you passed it, so if you migrated
with your compile-time graph, every kind added at runtime disappeared from the
schema while its rows stayed in the tables where nothing could reach them. The
usual way you’d end up calling &lt;code dir=&quot;auto&quot;&gt;migrateSchema()&lt;/code&gt; was the library’s own error
message telling you to review the changes and run it, so following my advice
hid your data.&lt;/p&gt;
&lt;p&gt;0.44 folds the stored runtime additions in before committing, the same way
store creation already did. It also throws if a migration would drop a kind
that still holds rows, unless you pass &lt;code dir=&quot;auto&quot;&gt;{ discardDroppedKindRows: true }&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Two races in the same area got fixed alongside it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A schema commit could land while another writer was mid-write against the
version being replaced. Managed writes now recheck their schema version
while holding a lock the commit also needs, so a stale write fails instead
of landing against a schema that no longer accepts it.&lt;/li&gt;
&lt;li&gt;Removing a kind and adding it back before its cleanup ran made the old
rows reappear next to the new ones, and the cleanup then skipped them
because the kind was live again. &lt;code dir=&quot;auto&quot;&gt;evolve()&lt;/code&gt; now won’t re-add a kind while
its cleanup is pending, and cleanup rechecks the schema under the same lock.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;an-implies-that-folded-nonsense-rows&quot;&gt;An &lt;code dir=&quot;auto&quot;&gt;implies()&lt;/code&gt; that folded nonsense rows&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is the older one, from 0.35. &lt;code dir=&quot;auto&quot;&gt;implies(edgeA, edgeB)&lt;/code&gt; means “a traversal
of &lt;code dir=&quot;auto&quot;&gt;edgeB&lt;/code&gt; can include &lt;code dir=&quot;auto&quot;&gt;edgeA&lt;/code&gt; edges”, which only makes sense if &lt;code dir=&quot;auto&quot;&gt;edgeA&lt;/code&gt;’s
endpoints could stand in for &lt;code dir=&quot;auto&quot;&gt;edgeB&lt;/code&gt;’s. Nothing checked that, so this was
accepted:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;edges: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;authored: { type: authored, from: [Author], to: [Paper] },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;covers: { type: covers, from: [Paper], to: [Topic] },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ontology: [&lt;/span&gt;&lt;span&gt;implies&lt;/span&gt;&lt;span&gt;(authored, covers)],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;and a &lt;code dir=&quot;auto&quot;&gt;covers&lt;/code&gt; traversal with &lt;code dir=&quot;auto&quot;&gt;expand: &quot;implying&quot;&lt;/code&gt; would fold in rows that
start at an &lt;code dir=&quot;auto&quot;&gt;Author&lt;/code&gt;. Now it fails when the graph is built or loaded:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ConfigurationError: implies(&quot;authored&quot;, &quot;covers&quot;) is endpoint-incompatible:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from kind(s) [Author] declared on &quot;authored&quot; cannot be assigned to any of&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&quot;covers&quot;&apos;s from kind(s) [Paper].&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The check also runs on schemas loaded from the database, so a bad relation
saved by an older version fails on first load after upgrading. The same
release made projected ids keep their &lt;code dir=&quot;auto&quot;&gt;NodeId&amp;#x3C;N&gt;&lt;/code&gt; brand through &lt;code dir=&quot;auto&quot;&gt;.select()&lt;/code&gt;
and gave every fixed-shape error class a typed &lt;code dir=&quot;auto&quot;&gt;details&lt;/code&gt;, so fewer mistakes
make it to runtime in the first place.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-they-have-in-common&quot;&gt;What they have in common&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;All five fixes follow the rules I now hold the whole library to. If TypeGraph
can’t do what you asked, it throws and tells you why rather than returning an
empty result that looks like a clean one, and it doesn’t rewrite your data
behind your back, even to fix it. You get a report first and decide what to
do.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/schema-management#repairing-inverted-validity-windows&quot;&gt;Repairing inverted validity windows&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/backend-setup#claim-relations-and-what-they-do-not-promise&quot;&gt;Claim relations, and what they do not promise&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/troubleshooting#contribution-health-probe-repair-rebuild&quot;&gt;Contribution health: probe, repair, rebuild&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/schema-management&quot;&gt;Schema Management&lt;/a&gt;: migrations and kind removal&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>An Infinite Supply of Graph Databases</title><link>https://typegraph.dev/blog/infinite-graph-databases/</link><guid isPermaLink="true">https://typegraph.dev/blog/infinite-graph-databases/</guid><description>Put TypeGraph inside a Cloudflare Durable Object and every tenant gets a private graph database that exists the moment you name it and costs nothing while idle.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;TypeGraph turns a SQLite (or PGlite, or Postgres) connection into a typed
property graph. If you point that connection at a Cloudflare Durable Object’s
own storage, you &lt;strong&gt;no longer have to provision the database at all&lt;/strong&gt;. Every
user, agent conversation, or workspace can have its own graph database without
anyone ever creating it, and I think that’s a lot of fun.&lt;/p&gt;
&lt;p&gt;I built a companion repo to show it off,
&lt;a href=&quot;https://github.com/nicia-ai/cf-do-typegraph&quot;&gt;cf-do-typegraph&lt;/a&gt;. It wires
TypeGraph into a single Durable Object class and runs three real apps on
top, entirely on your laptop under &lt;code dir=&quot;auto&quot;&gt;wrangler dev&lt;/code&gt;. It’s MIT-licensed.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;naming-is-the-provisioning-step&quot;&gt;Naming is the provisioning step&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s the whole multi-tenant boundary:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// the whole multi-tenant boundary, trimmed from src/worker.ts&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;stub&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;env&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;GRAPH_DO&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(env&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;GRAPH_DO&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;idFromName&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;example&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;tenant&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; stub&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;fetch&lt;/span&gt;&lt;span&gt;(request); &lt;/span&gt;&lt;span&gt;// that tenant&apos;s graph, and nothing else, lives here&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;idFromName&lt;/code&gt; is effectively the provisioning API. The Durable Object it names,
and the private SQLite database inside it, comes into existence on the first
request and hibernates as soon as nothing is using it, so there’s no
&lt;code dir=&quot;auto&quot;&gt;CREATE DATABASE&lt;/code&gt;, no row to add to a &lt;code dir=&quot;auto&quot;&gt;tenants&lt;/code&gt; table, and no migration to run
against a database that didn’t exist five minutes ago. Giving every user,
agent, or document its own graph stops being a capacity-planning question,
because each one is just a name.&lt;/p&gt;
&lt;p&gt;Minting a lot of them is just a loop:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-X&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;POST&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;localhost:8787/api/spawn/notes?count=25&amp;#x26;prefix=demo&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{ &lt;/span&gt;&lt;span&gt;&quot;example&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;notes&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;seeded&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;demo-1&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;demo-2&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;demo-25&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;] }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;localhost:8787/api/notes/demo-7/search?q=hibernation&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;# its own private graph&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That endpoint forwards 25 &lt;code dir=&quot;auto&quot;&gt;/seed&lt;/code&gt; requests to 25 different names and lets
each Durable Object materialize when its request arrives. There’s nothing
more to it than that.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;isolation-and-shared-transactions&quot;&gt;Isolation and shared transactions&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Making each tenant a Durable Object has two consequences that matter more than
the provisioning trick.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Isolation is physical.&lt;/strong&gt; Each tenant’s SQLite file is its own &lt;code dir=&quot;auto&quot;&gt;ctx.storage&lt;/code&gt;
rather than a slice of a shared table. The usual SaaS approach is a
&lt;code dir=&quot;auto&quot;&gt;tenant_id&lt;/code&gt; column plus a promise that every query remembers its
&lt;code dir=&quot;auto&quot;&gt;WHERE tenant_id = ?&lt;/code&gt;, but here there’s no shared table to put that column in,
so a bad join or a missing filter can only ever see one tenant’s data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The graph and the app’s data live in the same file and the same
transaction.&lt;/strong&gt; The Durable Object boots its store the same way on every
request:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;src/do/graph-do.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;createSqliteBackend&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;drizzle&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;storage&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;ctx.storage&lt;/code&gt; is the same SQLite the application would use for its regular
tables, and TypeGraph doesn’t need a separate connection or service, so
writing an app row and the graph edge that describes it can be one
&lt;code dir=&quot;auto&quot;&gt;store.transaction(...)&lt;/code&gt;. There’s no sync job between your data and your
graph, and nothing for them to drift apart on.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;three-apps-one-durable-object-class&quot;&gt;Three apps, one Durable Object class&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The repo runs the same &lt;code dir=&quot;auto&quot;&gt;GraphDO&lt;/code&gt; class three ways, distinguished only by
what you name the tenant. I picked each example because it needs a query
that’s painful to hand-write in SQL.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;authz-a-graph-per-workspace&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;authz&lt;/code&gt;: a graph per workspace&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Zanzibar-style permission checks are reachability questions, and
TypeGraph’s ontology does the reasoning you’d otherwise hand-roll in a
&lt;code dir=&quot;auto&quot;&gt;WITH RECURSIVE&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;src/examples/authz/graph.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ontology: [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;implies&lt;/span&gt;&lt;span&gt;(owner, editor), &lt;/span&gt;&lt;span&gt;// owner ⇒ editor ⇒ viewer&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;implies&lt;/span&gt;&lt;span&gt;(editor, viewer),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;inverseOf&lt;/span&gt;&lt;span&gt;(memberOf, hasMember), &lt;/span&gt;&lt;span&gt;// group membership, read backwards, zero stored reverse edges&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A permission check is one &lt;code dir=&quot;auto&quot;&gt;shortestPath&lt;/code&gt; call over the edge kinds the
requested relation implies:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;localhost:8787/api/authz/acme/check?user=alice&amp;#x26;relation=editor&amp;#x26;doc=roadmap&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;allowed&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;relation&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;editor&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;subject&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;user:alice&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;via&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;grantSubject&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;group:staff&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;grantRelation&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;editor&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;grantTarget&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;folder:engineering&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;pathNodeIds&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;user:alice&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;group:eng&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;group:staff&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;folder:engineering&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;folder:specs&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;doc:roadmap&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;pathEdgeKinds&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;memberOf&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;memberOf&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;editor&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;parentOf&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;parentOf&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Alice never got a direct grant on that doc, and the answer comes with the
path that proves she has access anyway: nested group membership (&lt;code dir=&quot;auto&quot;&gt;alice&lt;/code&gt; →
&lt;code dir=&quot;auto&quot;&gt;eng&lt;/code&gt; → &lt;code dir=&quot;auto&quot;&gt;staff&lt;/code&gt;), one group-level &lt;code dir=&quot;auto&quot;&gt;editor&lt;/code&gt; grant, and two levels of folder
inheritance, all inferred at query time from five stored edges without a
permissions table.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;notes-a-graph-per-user&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;notes&lt;/code&gt;: a graph per user&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;This one is a personal wiki with &lt;code dir=&quot;auto&quot;&gt;[[wiki-links]]&lt;/code&gt;, and the fun part is that
&lt;strong&gt;FTS5 runs inside the Durable Object’s own SQLite&lt;/strong&gt;, so there’s no search
service to keep warm:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;localhost:8787/api/notes/me/search?q=reachability&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;hits&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;id&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;note:reachability&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;title&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Reachability&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;score&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;snippet&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&amp;#x3C;mark&gt;Reachability&amp;#x3C;/mark&gt; asks whether you can get from one node to another...&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Backlinks don’t need any stored rows. The graph only writes a forward &lt;code dir=&quot;auto&quot;&gt;linksTo&lt;/code&gt;
edge when a note contains &lt;code dir=&quot;auto&quot;&gt;[[Some Target]]&lt;/code&gt;, and one ontology declaration
lets a &lt;code dir=&quot;auto&quot;&gt;linkedFrom&lt;/code&gt; traversal read those same edges backwards:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;src/examples/notes/graph.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ontology: [&lt;/span&gt;&lt;span&gt;inverseOf&lt;/span&gt;&lt;span&gt;(linksTo, linkedFrom)],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;There’s no second edge to keep in sync, so backlinks can’t drift from the
links that produced them.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;agent-memory-a-graph-per-conversation&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;agent-memory&lt;/code&gt;: a graph per conversation&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Each session is its own graph, seeded by replaying a hand-written
conversation one message at a time:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&quot;I&apos;m Ada, and I&apos;m PMing the Halo launch.&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&quot;Grace is my lead engineer — we&apos;ve shipped three products together.&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&quot;New development: we&apos;re partnering with Northwind, and Grace is their main contact.&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That eighth message is my favorite part of the repo. &lt;code dir=&quot;auto&quot;&gt;Organization&lt;/code&gt; isn’t a
node kind in the compile-time schema, so it arrives at runtime, gets validated
the same way an LLM’s proposed schema change would be, and is applied live:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;src/examples/agent-memory/replay.ts&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;validation&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;validateGraphExtension&lt;/span&gt;&lt;span&gt;(evolution&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;extension&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;strict: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;current &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; current&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;evolve&lt;/span&gt;&lt;span&gt;(validation&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The graph grows a new node kind and a new &lt;code dir=&quot;auto&quot;&gt;worksAt&lt;/code&gt; edge kind in the middle
of a conversation, while the Durable Object is running. The change is
persisted, so it’s still there the next time the object wakes from
hibernation. And because the store boots with &lt;code dir=&quot;auto&quot;&gt;{ history: true }&lt;/code&gt;, the same
graph can answer “what did the agent believe after message 4?”, before
Northwind existed, by reading at a recorded point in time instead of the
live state.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-same-pattern-without-cloudflare&quot;&gt;The same pattern without Cloudflare&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The underlying idea, &lt;strong&gt;a small, typed graph database per tenant, addressed by
name&lt;/strong&gt;, works well whenever tenants are numerous, mostly idle, and shouldn’t
share a query surface. Durable Objects make it especially easy (SQLite at the
edge, isolated per object, and hibernating for free), but TypeGraph doesn’t
know or care that one is underneath, and the same pattern works with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A directory of SQLite files.&lt;/strong&gt; One file per tenant, opened through
&lt;code dir=&quot;auto&quot;&gt;createLocalSqliteStore&lt;/code&gt;. Provisioning is choosing a file path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A pool of PGlite files.&lt;/strong&gt; Postgres-in-WASM via &lt;code dir=&quot;auto&quot;&gt;createLocalPgliteStore&lt;/code&gt;,
one file per tenant, no server process.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A database per Neon branch.&lt;/strong&gt; Real network Postgres, with the standard
&lt;code dir=&quot;auto&quot;&gt;createPostgresBackend&lt;/code&gt; pointed at a different connection string per
tenant, provisioned through Neon’s API instead of &lt;code dir=&quot;auto&quot;&gt;idFromName&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I built the demo on Durable Objects because they need the least
infrastructure to try: there’s no server to run, and you don’t need an account
to develop against them.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;tested-against-the-real-thing&quot;&gt;Tested against the real thing&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every example runs its tests against real Durable Objects via
&lt;code dir=&quot;auto&quot;&gt;@cloudflare/vitest-pool-workers&lt;/code&gt;, not mocks, including a cross-tenant
isolation test that seeds one tenant and checks that a same-shaped,
differently named tenant reads back empty. &lt;code dir=&quot;auto&quot;&gt;pnpm dev&lt;/code&gt; runs the whole thing
(landing page, live force-directed graph view, all three APIs) locally for
free. Workers AI, the one billable piece, is only used for optional
semantic search and entity extraction, and ships commented out.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/cf-do-typegraph&quot;&gt;cf-do-typegraph&lt;/a&gt;:
&lt;code dir=&quot;auto&quot;&gt;pnpm install &amp;#x26;&amp;#x26; pnpm dev&lt;/code&gt;, then open &lt;code dir=&quot;auto&quot;&gt;localhost:8787&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/blog/bring-your-own-database&quot;&gt;Bring Your Own Database&lt;/a&gt;: the backend
abstraction that lets the same store run on a different SQLite underneath&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/blog/truth-maintenance-for-agent-memory&quot;&gt;Agent Memory That Knows Why It Believes Things&lt;/a&gt;:
the bitemporal history behind the &lt;code dir=&quot;auto&quot;&gt;agent-memory&lt;/code&gt; replay&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/blog/runtime-schema-evolution&quot;&gt;An Agent That Grows Its Own Schema&lt;/a&gt;: &lt;code dir=&quot;auto&quot;&gt;store.evolve()&lt;/code&gt;,
the mechanism behind the live &lt;code dir=&quot;auto&quot;&gt;Organization&lt;/code&gt; extension&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>TypeGraph vs. Neo4j, LadybugDB and pgGraph: Who Wins What</title><link>https://typegraph.dev/blog/benchmarking-typegraph-neo4j-ladybugdb/</link><guid isPermaLink="true">https://typegraph.dev/blog/benchmarking-typegraph-neo4j-ladybugdb/</guid><description>I ran 17 LDBC queries against five engines at two scales. TypeGraph on SQLite wins every point read, often by 10–100x, and the engines built around an in-memory graph index win whole-graph algorithms by three to four orders of magnitude.</description><pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;My first pass at benchmarking TypeGraph against real graph databases ran the
seven LDBC “short read” queries (IS1–IS7) against Neo4j and LadybugDB.
TypeGraph on SQLite won every one of them, which should have made me
suspicious rather than happy: IS1–IS7 are point lookups and one-hop walks, and
an in-process engine doing direct index seeks can’t lose that race to anything
that pays a network round trip.&lt;/p&gt;
&lt;p&gt;So I rebuilt the harness around the queries a graph database is &lt;em&gt;supposed&lt;/em&gt; to
win (shortest paths, bounded neighborhood walks, complex multi-hop reads, and
whole-graph algorithms) and ran 17 queries against five engines at two
scales. The short version:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TypeGraph on SQLite still wins every point read, usually by 10–100x.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The engines that keep an in-memory graph index win whole-graph algorithms
by three to four orders of magnitude&lt;/strong&gt;, and the gap gets wider as the data
grows.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I’m publishing both results rather than only the flattering one.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-setup&quot;&gt;The setup&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;All five engines run through one shared harness
(&lt;a href=&quot;https://github.com/nicia-ai/typegraph/tree/bench/pggraph-comparison-v2/packages/benchmarks/src/real&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;packages/benchmarks/src/real/&lt;/code&gt;&lt;/a&gt;):&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;3.53.2 (via &lt;code dir=&quot;auto&quot;&gt;better-sqlite3&lt;/code&gt; 12.11.1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL (TypeGraph backend)&lt;/td&gt;
&lt;td&gt;18.1 (&lt;code dir=&quot;auto&quot;&gt;pgvector/pgvector:pg18&lt;/code&gt; image)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neo4j&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;neo4j:2026.05.0&lt;/code&gt; server image, &lt;code dir=&quot;auto&quot;&gt;neo4j-driver&lt;/code&gt; 6.2.0, GDS plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LadybugDB&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;@ladybugdb/core&lt;/code&gt; 0.18.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pgGraph&lt;/td&gt;
&lt;td&gt;Evokoa pgGraph 0.1.8 (&lt;code dir=&quot;auto&quot;&gt;ghcr.io/evokoa/pggraph:0.1.8&lt;/code&gt;, bundles PostgreSQL 17)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;pgGraph is the new entrant, and I think it’s clever. Rather than being a
separate database, it’s a Postgres extension that builds a derived CSR
(compressed sparse row) index over ordinary normalized tables and exposes
traversal and pathfinding as SQL functions. Its point reads are tuned
Postgres, and the CSR index only comes into play once a query traverses.&lt;/p&gt;
&lt;p&gt;The 17 queries are the original IS1–IS7; IC13 (shortest path) and IC14
(weighted shortest path); BFS3, a bounded neighborhood walk; three complex
reads (IC2, IC8, IC9); and four graph-algorithm queries: GA_DEGREE, GA_WCC
(weakly connected components), and GA_BFS / GA_SSSP (whole-component
reachability and shortest-path depth from a seed).&lt;/p&gt;
&lt;p&gt;I held the comparison to two rules. Every result is checked with a
value-level digest, per row, across every engine that runs the query, and
every run below passed, since a fast wrong answer shouldn’t count. And nothing
is skipped silently: an engine without a comparable primitive for a query
reports a typed &lt;code dir=&quot;auto&quot;&gt;gap&lt;/code&gt;, so a &lt;code dir=&quot;auto&quot;&gt;gap&lt;/code&gt; cell means the engine can’t run that query
in a comparable form, not that I didn’t get around to it.&lt;/p&gt;
&lt;p&gt;Scales are &lt;strong&gt;SF1&lt;/strong&gt; (9,892 persons, 361K directed &lt;code dir=&quot;auto&quot;&gt;knows&lt;/code&gt; edges) and &lt;strong&gt;SF10&lt;/strong&gt;
(65,645 persons, 3.88M &lt;code dir=&quot;auto&quot;&gt;knows&lt;/code&gt;, 21.9M comments). Each is one run on an EC2
host with shared vCPUs, so treat sub-millisecond cells and anything flagged
noisy as order-of-magnitude.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;sf1&quot;&gt;SF1&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;p50 latency in milliseconds unless noted. Fastest engine per row in &lt;strong&gt;bold&lt;/strong&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;typegraph-sqlite&lt;/th&gt;
&lt;th&gt;typegraph-postgres&lt;/th&gt;
&lt;th&gt;neo4j&lt;/th&gt;
&lt;th&gt;ladybugdb&lt;/th&gt;
&lt;th&gt;pggraph&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IS1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.03&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.76&lt;/td&gt;
&lt;td&gt;4.84&lt;/td&gt;
&lt;td&gt;1.11&lt;/td&gt;
&lt;td&gt;0.93&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.85&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;21.5&lt;/td&gt;
&lt;td&gt;39.9&lt;/td&gt;
&lt;td&gt;76.0&lt;/td&gt;
&lt;td&gt;19.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.24&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.82&lt;/td&gt;
&lt;td&gt;4.07&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;td&gt;1.47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.02&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.98&lt;/td&gt;
&lt;td&gt;3.01&lt;/td&gt;
&lt;td&gt;0.39&lt;/td&gt;
&lt;td&gt;0.91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.03&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.08&lt;/td&gt;
&lt;td&gt;3.1&lt;/td&gt;
&lt;td&gt;1.61&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.07&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.97&lt;/td&gt;
&lt;td&gt;3.23&lt;/td&gt;
&lt;td&gt;3.93&lt;/td&gt;
&lt;td&gt;1.89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.07&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2.15&lt;/td&gt;
&lt;td&gt;5.74&lt;/td&gt;
&lt;td&gt;7.83&lt;/td&gt;
&lt;td&gt;1.76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IC13 (shortest path)&lt;/td&gt;
&lt;td&gt;3.88&lt;/td&gt;
&lt;td&gt;22.3&lt;/td&gt;
&lt;td&gt;3.24&lt;/td&gt;
&lt;td&gt;9.91&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.18&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IC14 (weighted SP)&lt;/td&gt;
&lt;td&gt;5586&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4860&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFS3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;220&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1139&lt;/td&gt;
&lt;td&gt;468&lt;/td&gt;
&lt;td&gt;1539&lt;/td&gt;
&lt;td&gt;357&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IC2&lt;/td&gt;
&lt;td&gt;51.8&lt;/td&gt;
&lt;td&gt;523&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;36.2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;89.1&lt;/td&gt;
&lt;td&gt;347&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IC8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.06&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;17.3&lt;/td&gt;
&lt;td&gt;3.73&lt;/td&gt;
&lt;td&gt;24.2&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IC9&lt;/td&gt;
&lt;td&gt;2236&lt;/td&gt;
&lt;td&gt;15069&lt;/td&gt;
&lt;td&gt;3388&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;775&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3498&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA_DEGREE&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.03&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.09&lt;/td&gt;
&lt;td&gt;2.97&lt;/td&gt;
&lt;td&gt;1.8&lt;/td&gt;
&lt;td&gt;0.72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA_WCC&lt;/td&gt;
&lt;td&gt;7269&lt;/td&gt;
&lt;td&gt;24237&lt;/td&gt;
&lt;td&gt;18.7&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.53&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA_BFS&lt;/td&gt;
&lt;td&gt;221&lt;/td&gt;
&lt;td&gt;1828&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;288&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA_SSSP&lt;/td&gt;
&lt;td&gt;220&lt;/td&gt;
&lt;td&gt;1742&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23.6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;284&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div&gt;&lt;h3 id=&quot;point-reads&quot;&gt;Point reads&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;TypeGraph on SQLite takes every IS row, often by one to two orders of
magnitude, because it’s the only engine here that pays no network round trip
and no per-call query planning overhead. GA_DEGREE and
IC8 go the same way, because underneath the “algorithm” and “complex read”
labels they’re point lookups too.&lt;/p&gt;
&lt;p&gt;This is the case for an embedded graph. Most of what an application asks its
graph all day looks like IS1–IS7 (fetch this person, their recent posts,
who they know), and on IS1 Neo4j takes 4.84ms where SQLite takes 0.03ms.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;graph-algorithms&quot;&gt;Graph algorithms&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;GA_WCC, GA_BFS, GA_SSSP, and IC13 are what a graph engine’s specialized index
exists for, and here the CSR engines are in a different league:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;pgGraph wins GA_WCC (9.53ms) and IC13 (2.18ms)&lt;/strong&gt;, running union-find and
shortest path directly over its CSR index.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Neo4j with the GDS plugin wins GA_BFS (24.5ms) and GA_SSSP (23.6ms).&lt;/strong&gt;
Without GDS, Neo4j answers these with Cypher path enumeration, which works
but is slow. With GDS it projects the graph into memory once and runs the
same kind of set-based traversal pgGraph does.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeGraph is three to four orders of magnitude slower on GA_WCC&lt;/strong&gt; (7,269ms
on SQLite, 24,237ms on Postgres, against pgGraph’s 9.53ms).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last gap isn’t a bug I can fix. TypeGraph’s algorithms run as rounds of
SQL, one window or aggregate query per round, however well indexed, while
pgGraph and GDS hold the graph in an in-memory structure built for this access
pattern, and query tuning won’t make SQL iteration behave like a CSR
traversal.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;sf10&quot;&gt;SF10&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The SF10 run happened &lt;strong&gt;before&lt;/strong&gt; I added the GDS plugin to the Neo4j setup, so
Neo4j’s graph-algorithm rows show &lt;code dir=&quot;auto&quot;&gt;gap&lt;/code&gt; here even though Neo4j can run them.
For Neo4j on those queries, the SF1 table above is the current picture.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;s&lt;/code&gt; = seconds; otherwise milliseconds.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;typegraph-sqlite&lt;/th&gt;
&lt;th&gt;typegraph-postgres&lt;/th&gt;
&lt;th&gt;neo4j&lt;/th&gt;
&lt;th&gt;ladybugdb&lt;/th&gt;
&lt;th&gt;pggraph&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IS1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.03&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.06&lt;/td&gt;
&lt;td&gt;5.22&lt;/td&gt;
&lt;td&gt;1.12&lt;/td&gt;
&lt;td&gt;0.93&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.35&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;77.2&lt;/td&gt;
&lt;td&gt;133&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;22.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.42&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6.23&lt;/td&gt;
&lt;td&gt;53.8&lt;/td&gt;
&lt;td&gt;15.7&lt;/td&gt;
&lt;td&gt;1.87&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.03&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.91&lt;/td&gt;
&lt;td&gt;3.33&lt;/td&gt;
&lt;td&gt;0.90&lt;/td&gt;
&lt;td&gt;0.96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.04&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8.67&lt;/td&gt;
&lt;td&gt;5.52&lt;/td&gt;
&lt;td&gt;2.68&lt;/td&gt;
&lt;td&gt;0.96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.07&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.69&lt;/td&gt;
&lt;td&gt;8.09&lt;/td&gt;
&lt;td&gt;5.50&lt;/td&gt;
&lt;td&gt;2.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IS7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.07&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7.29&lt;/td&gt;
&lt;td&gt;8.23&lt;/td&gt;
&lt;td&gt;8.38&lt;/td&gt;
&lt;td&gt;1.80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IC13 (shortest path)&lt;/td&gt;
&lt;td&gt;35.0&lt;/td&gt;
&lt;td&gt;339&lt;/td&gt;
&lt;td&gt;82.2&lt;/td&gt;
&lt;td&gt;161&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IC14 (weighted SP)&lt;/td&gt;
&lt;td&gt;74.6s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;57.3s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFS3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.7s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7.2s&lt;/td&gt;
&lt;td&gt;3.9s&lt;/td&gt;
&lt;td&gt;9.3s&lt;/td&gt;
&lt;td&gt;2.2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IC2&lt;/td&gt;
&lt;td&gt;141&lt;/td&gt;
&lt;td&gt;724&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;138&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;296&lt;/td&gt;
&lt;td&gt;843&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IC8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.24&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;220&lt;/td&gt;
&lt;td&gt;92.1&lt;/td&gt;
&lt;td&gt;47.6&lt;/td&gt;
&lt;td&gt;15.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IC9&lt;/td&gt;
&lt;td&gt;11.9s&lt;/td&gt;
&lt;td&gt;76.7s&lt;/td&gt;
&lt;td&gt;15.5s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.1s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;25.4s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA_DEGREE&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.06&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.05&lt;/td&gt;
&lt;td&gt;3.02&lt;/td&gt;
&lt;td&gt;2.07&lt;/td&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA_WCC&lt;/td&gt;
&lt;td&gt;119.9s&lt;/td&gt;
&lt;td&gt;506.0s&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;69.0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA_BFS&lt;/td&gt;
&lt;td&gt;2.8s&lt;/td&gt;
&lt;td&gt;22.3s&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.0s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA_SSSP&lt;/td&gt;
&lt;td&gt;2.8s&lt;/td&gt;
&lt;td&gt;22.3s&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;gap&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.9s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Point reads barely move at 10x the data, as you’d expect from an indexed
seek. The more interesting number is how GA_WCC grows:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;SF1&lt;/th&gt;
&lt;th&gt;SF10&lt;/th&gt;
&lt;th&gt;Growth&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pggraph&lt;/td&gt;
&lt;td&gt;9.53ms&lt;/td&gt;
&lt;td&gt;69.0ms&lt;/td&gt;
&lt;td&gt;~7x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typegraph-sqlite&lt;/td&gt;
&lt;td&gt;7269ms&lt;/td&gt;
&lt;td&gt;119.9s&lt;/td&gt;
&lt;td&gt;~16x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typegraph-postgres&lt;/td&gt;
&lt;td&gt;24237ms&lt;/td&gt;
&lt;td&gt;506.0s&lt;/td&gt;
&lt;td&gt;~21x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;pgGraph grows a bit less than linearly with the data, while TypeGraph’s
round-by-round algorithm grows faster than linearly because more edges also
means more rounds, so the gap gets bigger as the data grows. If you need
whole-graph analytics over millions of edges on a schedule, use a specialized
engine for that job.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;sqlite-beats-postgres-even-inside-typegraph&quot;&gt;SQLite beats Postgres, even inside TypeGraph&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Both TypeGraph backends run the same logical algorithms, and SQLite is 4–8x
faster on the heavy ones at SF10 (GA_WCC 119.9s vs 506.0s, GA_BFS 2.8s vs
22.3s, IC9 11.9s vs 76.7s). My first guess was network round trips, but that
doesn’t hold up: GA_BFS already issues one &lt;code dir=&quot;auto&quot;&gt;INSERT ... RETURNING&lt;/code&gt; per round,
Postgres is on loopback, and the ratio stays about 8x at both scales, whereas
a fixed per-call cost would shrink as a share of a longer run. That points at
a per-row cost in how Postgres executes these queries, which I haven’t tracked
down yet.&lt;/p&gt;
&lt;p&gt;The one exception is IC14, the weighted shortest path, where Postgres wins.
Its set-based frontier expansion handles a large, unbounded Dijkstra better
than SQLite’s row-at-a-time version.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;ic14-the-one-only-typegraph-ran&quot;&gt;IC14: the one only TypeGraph ran&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;IC14 asks for the lowest-cost path between two people rather than the fewest
hops. In this lineup, nothing else could run it in a comparable form:
Neo4j’s GDS has no stored &lt;code dir=&quot;auto&quot;&gt;knows&lt;/code&gt; weight to project, pgGraph’s shortest path
counts hops only, and LadybugDB’s weighted shortest path isn’t wired into the
harness yet. TypeGraph answers it on both backends with
&lt;code dir=&quot;auto&quot;&gt;store.algorithms.weightedShortestPath&lt;/code&gt;, at real LDBC scale (5.6s / 4.9s at
SF1, 75s / 57s at SF10), with byte-identical results across the two.&lt;/p&gt;
&lt;p&gt;Those times aren’t fast, and the benchmark makes them look worse than real
use would, because it picks random pairs near the graph’s diameter, which is
the worst case for single-source Dijkstra. Real weighted-path questions tend to be between
related, nearby entities, where the same algorithm stops early.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;loading&quot;&gt;Loading&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Loading is where TypeGraph still trails. It improved a lot between my first
run and this one, because in the meantime 0.37 shipped a trusted initial
import, and the benchmark loader now uses it.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;importGraph&lt;/code&gt; validates every row it writes: schema shape, edge endpoints,
cardinality, conflicts. That’s the right default, since most imports come
from somewhere you don’t fully trust. But when you’re filling a brand-new
database from an export you produced and already validated, every one of
those checks is wasted work. &lt;code dir=&quot;auto&quot;&gt;trustedImportGraph&lt;/code&gt; and
&lt;code dir=&quot;auto&quot;&gt;trustedImportGraphStream&lt;/code&gt; skip them. They bypass the normal write pipeline,
drop secondary indexes, insert straight into the tables, then rebuild the
indexes and refresh statistics, all in one transaction.&lt;/p&gt;
&lt;p&gt;Loading the same 200,000 nodes and 200,000 edges into a fresh SQLite
database both ways:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;run 1 — importGraph: 6783ms   trustedImportGraphStream: 2455ms&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;run 2 — importGraph: 7253ms   trustedImportGraphStream: 2206ms&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;run 3 — importGraph: 5048ms   trustedImportGraphStream: 2116ms&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Trusted import was 2.5–3x faster on every run. The streaming form takes a header, then node
chunks, then edge chunks, so the loader here reads the LDBC CSVs in two
bounded passes instead of holding a multi-million-row graph in memory.&lt;/p&gt;
&lt;p&gt;Skipping validation needs a narrow contract. The node and edge tables must
be completely empty, and TypeGraph only checks stream order and kind names.
Property shapes, endpoints, and duplicate-free IDs are on you. Features whose
extra writes it would otherwise skip (history, uniqueness constraints,
&lt;code dir=&quot;auto&quot;&gt;searchable()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;embedding()&lt;/code&gt; fields) are rejected outright. Point it at
a database with rows in it and it throws before touching anything:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;TrustedImportError: Trusted import requires globally empty TypeGraph node and edge tables.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;details: { tables: [&quot;typegraph_nodes&quot;, &quot;typegraph_edges&quot;], reason: &quot;database_not_empty&quot; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;For anything that isn’t a one-time load of a fresh, dedicated database, use
&lt;code dir=&quot;auto&quot;&gt;importGraph&lt;/code&gt; (untrusted data, conflicts, history, search fields) or
collection &lt;code dir=&quot;auto&quot;&gt;bulkInsert&lt;/code&gt; (trusted data going into a database that isn’t
empty).&lt;/p&gt;
&lt;p&gt;Here’s what it did to the benchmark:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;SF1 load&lt;/th&gt;
&lt;th&gt;Earlier run&lt;/th&gt;
&lt;th&gt;SF10 load&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ladybugdb&lt;/td&gt;
&lt;td&gt;46s&lt;/td&gt;
&lt;td&gt;41.6s&lt;/td&gt;
&lt;td&gt;373s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;neo4j&lt;/td&gt;
&lt;td&gt;66s&lt;/td&gt;
&lt;td&gt;71.4s&lt;/td&gt;
&lt;td&gt;409s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pggraph&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1,119s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typegraph-sqlite&lt;/td&gt;
&lt;td&gt;164s&lt;/td&gt;
&lt;td&gt;587.1s&lt;/td&gt;
&lt;td&gt;2,199s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typegraph-postgres&lt;/td&gt;
&lt;td&gt;344s&lt;/td&gt;
&lt;td&gt;669.3s&lt;/td&gt;
&lt;td&gt;3,278s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;TypeGraph on SQLite went from 587s to 164s (~3.6x) and on Postgres from 669s
to 344s (~1.9x). SQLite is now within about 1.4x of pgGraph, while
Postgres is still about 3x slower than pgGraph at both scales, because pgGraph
loads through batched &lt;code dir=&quot;auto&quot;&gt;INSERT&lt;/code&gt;s tuned for its own schema and TypeGraph’s
Postgres path still uses prepared statements instead of &lt;code dir=&quot;auto&quot;&gt;COPY&lt;/code&gt;. Switching to
&lt;code dir=&quot;auto&quot;&gt;COPY&lt;/code&gt; is next, and unlike most of what this benchmark turned up, it would
help every Postgres user.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;pggraph-as-an-accelerator&quot;&gt;pgGraph as an accelerator&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;pgGraph is the most interesting result here, less for the rows it wins than
for how it works. It indexes tables that already look a lot like the ones
TypeGraph’s Postgres backend writes (normalized nodes and edges in Postgres,
queryable over the same connection), which is why its point reads look like
tuned Postgres while its traversal numbers look like a graph engine’s.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;None of this is built yet.&lt;/strong&gt; The pgGraph driver in this benchmark loads its own copy
of the data into its own schema. It doesn’t sit on top of a TypeGraph-managed
database. But the numbers suggest the shape of a pairing: use TypeGraph for
the point reads and everyday writes it already wins, and when profiling finds
a real whole-graph workload (components, centrality, unweighted shortest path
at scale), build a pgGraph index over the same tables and send just that
query through it. I’m excited about that direction, because it keeps
everything in one database without giving up anything on the queries
applications run most.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-the-slow-rows-actually-mean&quot;&gt;What the slow rows actually mean&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;It’s tempting to read every slow cell as a TypeGraph bug. Mostly they aren’t:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;IC9 is a modeling artifact.&lt;/strong&gt; LDBC models a message’s creator as an edge,
so ranking a feed means fetching a sort key for every candidate first: 1.2M
of them at SF1 to return the top 20. A real feed would put the owner on the
item and index &lt;code dir=&quot;auto&quot;&gt;(owner, created_at)&lt;/code&gt;, which &lt;code dir=&quot;auto&quot;&gt;defineNodeIndex&lt;/code&gt; already
supports, so the fix belongs in the schema rather than the engine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The whole-graph algorithms are architectural.&lt;/strong&gt; Tuning has helped (a
delta-frontier rewrite of connected components nearly halved the Postgres
GA_WCC time during this work), but it won’t close a 1,000x gap. Pairing with
something like pgGraph for those queries is the realistic answer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IC14 is benchmark-amplified&lt;/strong&gt;, as above.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the caveats: one run per scale on a shared-vCPU host, so several cells
are noisy and should be read as order-of-magnitude. What I’m confident in
regardless: every query passes value-level parity across every engine that
runs it, and the direction of every finding is far larger than run-to-run
noise.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph/tree/bench/pggraph-comparison-v2/packages/benchmarks/src/real&quot;&gt;The harness&lt;/a&gt;:
all five engine drivers and the EC2 runner&lt;/li&gt;
&lt;li&gt;Full results and investigation notes:
&lt;a href=&quot;https://github.com/nicia-ai/typegraph/blob/bench/pggraph-comparison-v2/packages/benchmarks/reports/sf1-results.md&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;sf1-results.md&lt;/code&gt;&lt;/a&gt;,
&lt;a href=&quot;https://github.com/nicia-ai/typegraph/blob/bench/pggraph-comparison-v2/packages/benchmarks/reports/sf10-results.md&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;sf10-results.md&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/interchange#trusted-initial-import&quot;&gt;Trusted initial import&lt;/a&gt;: the full
contract&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/blog/typegraph-0-35-performance&quot;&gt;TypeGraph 0.35: Faster Almost Everywhere&lt;/a&gt;:
the fixes an earlier run of this benchmark turned up&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Agent Memory That Knows Why It Believes Things</title><link>https://typegraph.dev/blog/truth-maintenance-for-agent-memory/</link><guid isPermaLink="true">https://typegraph.dev/blog/truth-maintenance-for-agent-memory/</guid><description>Bitemporal recorded time and provenance-backed retraction let a graph say why it believes a fact, recompute what still holds when a source turns out wrong, and replay what it believed at any commit.</description><pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Say a vuln-feed agent in your overnight security pipeline flags 60 services
as shipping a vulnerable library, blocks their deploys, opens fix PRs, and
pages the owning teams. In the morning someone notices that the feed had a bad
package-name mapping, which pinned a real CVE to the wrong package across a
whole class of entries. What should happen to those 60 flags?&lt;/p&gt;
&lt;p&gt;Deleting all of them is wrong, because some were independently confirmed by a
SAST run or an SBOM check and those services really are vulnerable. Leaving
them is also wrong, because most were only ever the feed talking, and they’re
blocking shipments and paging people for nothing. What you need is the blast
radius: which of the 60 flags depend only on the feed, and which have other
evidence behind them.&lt;/p&gt;
&lt;p&gt;Most agent memory can’t answer that, because “memory” today usually means
retrieval: embed everything, fetch the nearest neighbors, and let the model
sort it out. That works for recall, but a vector store only keeps what was
said. It has no record of &lt;em&gt;why&lt;/em&gt; something was concluded, or of what should
happen to a conclusion when one of its sources turns out to be wrong. You
could live with that when an agent was a single chat transcript, but not once
a fleet of agents writes into shared memory and acts on it.&lt;/p&gt;
&lt;p&gt;Over the last few releases I built the layer that answers the question: a
second temporal axis (0.33), provenance-backed retraction on top of it
(0.34), and the hardening that makes both hold up under real load (0.39 and
0.40). I’m really happy with how this one came out.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-blast-radius-as-a-query&quot;&gt;The blast radius, as a query&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;With the provenance layer, the support structure is right there in the
graph:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;VulnFeed ──▶ Vulnerable(svc-14) ──▶ BlockDeploy(svc-14)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;SASTRun  ──▶ Vulnerable(svc-14)          two sources, survives&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;VulnFeed ──▶ Vulnerable(svc-22) ──▶ BlockDeploy(svc-22)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                                         &lt;/span&gt;&lt;/span&gt;&lt;span&gt;feed only, dies&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Retract the feed and &lt;code dir=&quot;auto&quot;&gt;Vulnerable(svc-22)&lt;/code&gt; loses its only source, so it goes
non-current. That takes away the premise of &lt;code dir=&quot;auto&quot;&gt;BlockDeploy(svc-22)&lt;/code&gt;, which goes
non-current too, and the deploy unblocks. &lt;code dir=&quot;auto&quot;&gt;Vulnerable(svc-14)&lt;/code&gt; survives
because the SAST run still backs it, so that block stands.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;report&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;provenance&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;retract&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;VulnFeed&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id: &lt;/span&gt;&lt;span&gt;badMappingSnapshotId&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// report.died:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;//   flags and deploy blocks that had only the feed behind them&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;//&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// report.survivedVia:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;//   flags still confirmed by SAST or the SBOM check&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Run that across all 60 services and &lt;code dir=&quot;auto&quot;&gt;report&lt;/code&gt; is your cleanup list: &lt;code dir=&quot;auto&quot;&gt;died&lt;/code&gt;
tells you which deploys to unblock and which PRs to close, and &lt;code dir=&quot;auto&quot;&gt;survivedVia&lt;/code&gt;
tells you which flags are still real. I wouldn’t trust anyone to work that out
by hand at 7am.&lt;/p&gt;
&lt;p&gt;The source doesn’t have to be a feed; it can be another agent’s run. If a
triage agent combined an overnight feed-ingest run, a SAST scan, and an SBOM
rebuild into block-and-page decisions, and the feed-ingest run turns out to
have trusted bad data, you retract that run, not the whole fleet’s memory:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;report&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;provenance&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;retract&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;AgentRun&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id: &lt;/span&gt;&lt;span&gt;overnightFeedRunId&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Flags raised only by that run go non-current, while flags another run also
supports survive, so one agent’s mistake gets cleaned up without disturbing
what the others established.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;how-retraction-works&quot;&gt;How retraction works&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;@nicia-ai/typegraph/provenance&lt;/code&gt; subpath maps your existing graph kinds
onto four roles (sources, justifications, facts, and the edges between them)
and gives you a &lt;code dir=&quot;auto&quot;&gt;retract&lt;/code&gt; that recomputes support instead of deleting
blindly:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { createRetractionCapability } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;@nicia-ai/typegraph/provenance&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;provenance&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;createRetractionCapability&lt;/span&gt;&lt;span&gt;(store&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;source: { kinds:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;ScannerSource&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;VendorSource&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;justification: { kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Justification&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;fact: { kinds:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Vulnerability&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;DeployDecision&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;premiseOf: { kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;premiseOf&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;derives: { kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;derives&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A fact stays believed while at least one of its justifications has all its
premises still supported. Premises bottom out at sources. Retract a source
and every justification that leaned on it stops counting; a fact loses
currency only when it runs out of surviving justifications.&lt;/p&gt;
&lt;p&gt;Two properties make this safe to use. Retraction is &lt;strong&gt;scoped&lt;/strong&gt;, touching
only facts reachable from the sources that flipped, and it’s &lt;strong&gt;reversible&lt;/strong&gt;:
it changes whether a fact is believed without deleting anything, and the
fact’s edges stay put, so &lt;code dir=&quot;auto&quot;&gt;unRetract&lt;/code&gt; is an exact inverse of &lt;code dir=&quot;auto&quot;&gt;retract&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;None of this is new theory. The storage follows the JTMS shape from Doyle’s
1979 &lt;em&gt;A Truth Maintenance System&lt;/em&gt;: AND-justifications over premises, sources
as the base case, a fact believed only if some justification has all its premises supported. The
question &lt;code dir=&quot;auto&quot;&gt;retract&lt;/code&gt; answers (which facts keep support after a source drops
out) is the ATMS question from de Kleer’s 1986 work, and all of it runs on ordinary
SQL. It covers the well-founded, monotonic part of classic truth maintenance
rather than all of it. The new part is where it lives: your agent keeps
writing ordinary graph data and gets retraction semantics without moving to a
dedicated reasoning engine.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;replaying-what-the-agent-believed&quot;&gt;Replaying what the agent believed&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Retraction is much more useful with history. You want “why did the agent
block svc-22 at 2am, and why doesn’t it anymore?” to be a query rather than a
dig through logs, and recorded time is what makes that possible.&lt;/p&gt;
&lt;p&gt;TypeGraph already had valid time: when a fact was true in the world, set
with &lt;code dir=&quot;auto&quot;&gt;validFrom&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;validTo&lt;/code&gt; and read with &lt;code dir=&quot;auto&quot;&gt;store.asOf(T)&lt;/code&gt;. 0.33 adds the
second axis, &lt;strong&gt;recorded time&lt;/strong&gt;: when TypeGraph captured a fact, what the
system knew as of a commit. It’s the SQL:2011 &lt;code dir=&quot;auto&quot;&gt;FOR SYSTEM_TIME&lt;/code&gt; axis, or
Datomic’s system time. Turn it on per store:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;createStore&lt;/span&gt;&lt;span&gt;(graph&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;backend&lt;/span&gt;&lt;span&gt;, { history: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Writes through that store are captured with a per-graph, monotonic commit
anchor. &lt;code dir=&quot;auto&quot;&gt;store.asOfRecorded(T)&lt;/code&gt; gives you a read-only view of the graph as
of that anchor, and &lt;code dir=&quot;auto&quot;&gt;store.recordedNow()&lt;/code&gt; hands you the current one. The two
axes compose:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;asOf&lt;/span&gt;&lt;span&gt;(validTime)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;asOfRecorded&lt;/span&gt;&lt;span&gt;(recordedTime);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Retraction is a normal write under &lt;code dir=&quot;auto&quot;&gt;history: true&lt;/code&gt;, so the whole before and
after replays:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;before&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;recordedNow&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; provenance&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;retract&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;VulnFeed&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id: badMappingSnapshotId,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;after&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;recordedNow&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;asOfRecorded&lt;/span&gt;&lt;span&gt;(before)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;BlockDeploy&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getById&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;svc-22&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;); &lt;/span&gt;&lt;span&gt;// current&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;asOfRecorded&lt;/span&gt;&lt;span&gt;(after)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;BlockDeploy&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getById&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;svc-22&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;); &lt;/span&gt;&lt;span&gt;// non-current&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One naming note, since it trips up people coming from other systems:
TypeGraph’s &lt;code dir=&quot;auto&quot;&gt;asOf&lt;/code&gt; is valid time, the reverse of SQL:2011’s
&lt;code dir=&quot;auto&quot;&gt;FOR SYSTEM_TIME AS OF&lt;/code&gt; and Datomic’s &lt;code dir=&quot;auto&quot;&gt;d/as-of&lt;/code&gt;, where a bare “as of” means
system time. Valid-time reads are the common case here, so they got the
short name.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-plain-sql-and-what-it-costs&quot;&gt;Why plain SQL, and what it costs&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;There’s no portable, engine-native system versioning across Postgres and
SQLite. Postgres needs an extension or an application-level pattern, and
SQLite has nothing. So TypeGraph stores history in its own tables and
reconstructs point-in-time views in the query compiler. One implementation
runs on both backends, so the same memory model works from a solo agent’s
local SQLite file up to a fleet writing into shared Postgres.&lt;/p&gt;
&lt;p&gt;That isn’t free:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Only TypeGraph-managed writes are captured.&lt;/strong&gt; Raw &lt;code dir=&quot;auto&quot;&gt;tx.sql&lt;/code&gt; is disabled
on a history store, since it would bypass capture. This is an audit layer
for graph writes, not database-level CDC.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No backfill.&lt;/strong&gt; Enable history on a fresh graph. An entity that already
existed is first recorded the next time it’s written.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Recorded reads are an audit and replay tool, not a hot path.&lt;/strong&gt; They
reconstruct from history, so they’re slower than current-state reads, and
broad reads like &lt;code dir=&quot;auto&quot;&gt;find&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;search&lt;/code&gt;, and vector predicates aren’t available
at a past anchor because those indexes only reflect the present.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Writes cost more.&lt;/strong&gt; Roughly 2.5–6× an uncaptured write when each write is
its own transaction, dropping to about 1–1.5× when writes are batched.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;hardening-it-039-and-040&quot;&gt;Hardening it: 0.39 and 0.40&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Once real consumers started reading this axis, two gaps showed up.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Commits in the same millisecond.&lt;/strong&gt; 0.33 ordered commits by wall-clock
timestamp alone, which breaks exactly when writes get fast. Five
back-to-back writes:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;commit 0 anchor: r1:0000000000000001:2026-07-21T17:59:03.914Z&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;commit 1 anchor: r1:0000000000000002:2026-07-21T17:59:03.914Z&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;commit 2 anchor: r1:0000000000000003:2026-07-21T17:59:03.915Z&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;commit 3 anchor: r1:0000000000000004:2026-07-21T17:59:03.915Z&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;commit 4 anchor: r1:0000000000000005:2026-07-21T17:59:03.915Z&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Commits 0 and 1 share &lt;code dir=&quot;auto&quot;&gt;17:59:03.914Z&lt;/code&gt;. With a timestamp-only anchor, “the
graph right after commit 0 but before commit 1” doesn’t have an answer.
0.40 anchors are &lt;code dir=&quot;auto&quot;&gt;r1:&amp;#x3C;revision&gt;:&amp;#x3C;timestamp&gt;&lt;/code&gt;: the revision is a strict
per-graph counter, so every commit gets its own position no matter how many
share a millisecond. The timestamp is still there for display, and it never
moves backwards, even if the system clock does. A raw ISO string no longer
type-checks as an anchor, on purpose; get anchors from &lt;code dir=&quot;auto&quot;&gt;recordedNow()&lt;/code&gt;.
Deployments that adopted the earlier format run &lt;code dir=&quot;auto&quot;&gt;migrateLegacyRecordedTime()&lt;/code&gt;
once before opening the upgraded store.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Walking a whole snapshot.&lt;/strong&gt; 0.39 adds &lt;code dir=&quot;auto&quot;&gt;scan()&lt;/code&gt; to recorded collections:
bounded, deterministic pages (up to 1,000 per call) ordered by id, with a
cursor bound to the exact view it came from:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;view&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;asOfRecorded&lt;/span&gt;&lt;span&gt;(anchor);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;page1&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;view&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Item&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scan&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ limit: &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;page2&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;view&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Item&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scan&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ limit: &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;, after: &lt;/span&gt;&lt;span&gt;page1&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nextCursor&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Together those make “every row as of commit N, in order, one page at a time”
a supported operation for a replication tap, an audit export, or a search
index rebuild pinned to a point in history.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-agent-memory-needs-this&quot;&gt;Why agent memory needs this&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A fact stored without provenance is organizational hearsay: the memory can’t
tell you why it believes something or what should change when a source goes
bad. A single chat agent can get away with that because a lot of
inconsistency hides inside one transcript, but agents that share memory will
read stale feeds, trust bad mappings, and inherit each other’s wrong
assumptions.&lt;/p&gt;
&lt;p&gt;Memory that gates deploys, or maintains any long-lived model of a company,
needs to answer three questions: what do we believe, why do we believe it,
and what changes if this source turns out to be wrong. This layer is built to
answer those, and it runs on the SQL database you already have.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/provenance&quot;&gt;Provenance and Retraction&lt;/a&gt; and the
&lt;a href=&quot;https://typegraph.dev/examples/provenance-retraction/&quot;&gt;Provenance Retraction example&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/examples/bitemporal-time-travel/&quot;&gt;Bitemporal Time Travel example&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/queries/temporal#logical-revision-and-physical-time&quot;&gt;Logical revision and physical time&lt;/a&gt;:
the anchor format and diagonal bitemporal reads&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/schema-management#migrating-preview-recorded-time&quot;&gt;Migrating preview recorded time&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you’ve mapped JTMS/ATMS-style truth maintenance onto relational storage
elsewhere, or know of prior art that does, I’d like to hear about it. There’s
plenty written on truth maintenance systems and not much on doing it
directly on ordinary SQL tables.&lt;/p&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>The Cheapest Citation Lineage Isn&apos;t the Shortest One</title><link>https://typegraph.dev/blog/graph-algorithms/</link><guid isPermaLink="true">https://typegraph.dev/blog/graph-algorithms/</guid><description>Connected components, weighted shortest path, PageRank, personalized PageRank, and label propagation now run as SQL against your store. On an 18-paper citation graph they turn up things a citation count never would.</description><pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When I introduced TypeGraph I said there was no PageRank and no community
detection, and that if you needed those you wanted a real graph database.
That’s still partly true (more on that at the end), but as of 0.38 the list
is a lot shorter.&lt;/p&gt;
&lt;p&gt;Until now, &lt;code dir=&quot;auto&quot;&gt;store.algorithms&lt;/code&gt; answered questions about two nodes at a time,
like how to get from A to B or what’s within three hops of A. Some questions
need the whole graph at once: whether a dataset is one connected body or
several islands, which nodes matter most structurally rather than by raw
count, or whether communities emerge from the topology on their own.&lt;/p&gt;
&lt;p&gt;Answering those means running an algorithm round after round over the entire
graph, from one consistent snapshot, until it converges. That needs more
machinery than a single traversal, including a pinned transaction, a temporary
working table, and a clear rule for what happens when the rounds don’t
settle. 0.37 added
&lt;code dir=&quot;auto&quot;&gt;weaklyConnectedComponents&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;weightedShortestPath&lt;/code&gt;. 0.38 added global and
personalized &lt;code dir=&quot;auto&quot;&gt;pageRank&lt;/code&gt; and deterministic &lt;code dir=&quot;auto&quot;&gt;labelPropagation&lt;/code&gt;, the same
algorithm the LDBC Graphalytics benchmark uses for community detection. All
five run as SQL against the store, so you don’t export the graph to a separate
analytics engine and keep a second copy in sync.&lt;/p&gt;
&lt;p&gt;They’re also a lot of fun to play with, so the rest of this post runs them on
a small citation graph.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-corpus&quot;&gt;The corpus&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is the same citation graph as the
&lt;a href=&quot;https://typegraph.dev/examples/research-copilot&quot;&gt;research-copilot example&lt;/a&gt;: 18 landmark ML
papers, 55 authors, 14 topics, and 37 real citation edges, from Rumelhart,
Hinton &amp;#x26; Williams’ 1986 backprop paper through LLaMA in 2023. That example
runs point queries; here I run the whole-graph algorithms over the same data.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;backend&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;createExampleBackend&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const [&lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;] = await &lt;/span&gt;&lt;span&gt;createStoreWithSchema&lt;/span&gt;&lt;span&gt;(graph&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;backend);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Ingested 18 papers, 55 authors, 14 topics, 37 citation edges.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;one-body-of-work-or-islands&quot;&gt;One body of work, or islands?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Treat &lt;code dir=&quot;auto&quot;&gt;cites&lt;/code&gt; as undirected and partition by connectivity:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;components&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;algorithms&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;weaklyConnectedComponents&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;cites&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodeKinds:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Paper&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;18 papers partition into 1 component(s):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;• component of 18 paper(s), rooted at &quot;Adam: A Method for Stochastic Optimization&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Every paper reaches every other through some chain of citations. On a real,
messy dataset this is the sanity check to run first. &lt;code dir=&quot;auto&quot;&gt;nodeKinds: [&quot;Paper&quot;]&lt;/code&gt;
keeps authors and topics out of it, so more than one component would mean a
separate sub-literature rather than a lightly cited author hanging
off the edge.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-cheapest-lineage-isnt-the-shortest-one&quot;&gt;The cheapest lineage isn’t the shortest one&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is my favorite result in the post, and getting to it took one false
start.&lt;/p&gt;
&lt;p&gt;Citations always point from a newer paper to an older one, so the obvious edge
weight is &lt;code dir=&quot;auto&quot;&gt;yearGap&lt;/code&gt;, the number of years a citation reaches back. The trouble
is that every hop steps strictly backward in time, so the gaps along &lt;em&gt;any&lt;/em&gt;
route from A to B telescope to exactly &lt;code dir=&quot;auto&quot;&gt;A.year - B.year&lt;/code&gt;. Every path ties, and
weighting by &lt;code dir=&quot;auto&quot;&gt;yearGap&lt;/code&gt; is just &lt;code dir=&quot;auto&quot;&gt;shortestPath&lt;/code&gt; with extra arithmetic.&lt;/p&gt;
&lt;p&gt;Squaring the gap breaks the tie. &lt;code dir=&quot;auto&quot;&gt;yearGapCost = yearGap²&lt;/code&gt; is convex, so one
27-year leap costs &lt;code dir=&quot;auto&quot;&gt;27² = 729&lt;/code&gt; while the same span covered in several small
steps costs much less. The question becomes “what’s the smoothest chain of
ideas between these two papers,” and that’s where &lt;code dir=&quot;auto&quot;&gt;weightedShortestPath&lt;/code&gt;
starts disagreeing with &lt;code dir=&quot;auto&quot;&gt;shortestPath&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;hopPath&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;algorithms&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;shortestPath&lt;/span&gt;&lt;span&gt;(from&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;to&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;cites&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;maxHops: &lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;byConvexCost&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;algorithms&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;weightedShortestPath&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;to&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ edges:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;cites&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;, weightProperty: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;yearGapCost&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;transformer → backprop:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;shortestPath (fewest hop):   2 hops   transformer(2017) → adam(2014) → backprop(1986)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;weighted by yearGapCost:     4 hops   totalWeight=353   transformer(2017) → dropout(2014) → alexnet(2012) → lenet(1998) → backprop(1986)   ◀── more hops, lower convex cost&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;clip → backprop:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;shortestPath (fewest hop):   3 hops   clip(2021) → simclr(2020) → dropout(2014) → backprop(1986)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;weighted by yearGapCost:     7 hops   totalWeight=359   clip(2021) → gpt2(2019) → bert(2018) → transformer(2017) → dropout(2014) → alexnet(2012) → lenet(1998) → backprop(1986)   ◀── more hops, lower convex cost&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The fewest-hops route from the Transformer paper to backprop makes a 28-year
jump through Adam. The convex-cost route takes four smaller steps (Dropout,
AlexNet, LeNet) and comes in at less than half the cost. From CLIP it walks
seven hops, almost straight down the history of deep learning. Both are
legitimate answers to “what’s the best path” between the same two papers, and
I like that the convex-cost one reads like a syllabus.&lt;/p&gt;
&lt;p&gt;Weights are checked before any traversal round runs, so a negative or
non-numeric &lt;code dir=&quot;auto&quot;&gt;yearGapCost&lt;/code&gt; anywhere in the selected edges throws
&lt;code dir=&quot;auto&quot;&gt;InvalidEdgeWeightError&lt;/code&gt; up front instead of producing a wrong answer.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;pagerank-vs-counting-citations&quot;&gt;PageRank vs. counting citations&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A citation count tells you how many papers cite this one. PageRank tells you
how much a paper matters given &lt;em&gt;who&lt;/em&gt; cites it: a citation from an important
paper is worth more, and that carries through the graph.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;pageRankScores&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;algorithms&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;pageRank&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;cites&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodeKinds:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Paper&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;direction: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;out&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;// random surfer follows citations forward, toward the classics&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;PR-rank  score     cites  raw-rank  Δ  title&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;────────────────────────────────────────────────────────────────&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;     &lt;/span&gt;&lt;/span&gt;&lt;span&gt;1    0.24209      6       1    ·  Learning representations by back-propagating errors&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;     &lt;/span&gt;&lt;/span&gt;&lt;span&gt;2    0.09060      3       7   +5  ImageNet Classification with Deep Convolutional N...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;     &lt;/span&gt;&lt;/span&gt;&lt;span&gt;3    0.07559      3       6   +3  Efficient Estimation of Word Representations in V...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;     &lt;/span&gt;&lt;/span&gt;&lt;span&gt;4    0.07424      4       2   -2  Attention Is All You Need&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;     &lt;/span&gt;&lt;/span&gt;&lt;span&gt;5    0.05868      4       3   -2  BERT: Pre-training of Deep Bidirectional Transfor...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;     &lt;/span&gt;&lt;/span&gt;&lt;span&gt;6    0.05827      1      13   +7  Gradient-Based Learning Applied to Document Recog...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;     &lt;/span&gt;&lt;/span&gt;&lt;span&gt;7    0.05818      3       5   -2  Dropout: A Simple Way to Prevent Neural Networks ...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;     &lt;/span&gt;&lt;/span&gt;&lt;span&gt;8    0.05592      3       4   -4  Deep Residual Learning for Image Recognition&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Backprop wins both rankings, which is no surprise since it’s the root of the
whole corpus. The row I find interesting is 6th place. LeNet has exactly
&lt;strong&gt;one&lt;/strong&gt; citation in this corpus, which puts it 13th by count, but PageRank
moves it up seven places because that one citation comes from AlexNet, which
is heavily cited itself. A plain count would treat it like any other
citation.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-matters-to-clip-specifically&quot;&gt;What matters to CLIP, specifically&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Personalized PageRank runs the same iteration, but instead of jumping to a
random node it keeps jumping back to a seed you choose. The question changes
from “important globally” to “important from where CLIP is standing”:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;personalized&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;algorithms&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;personalizedPageRank&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;cites&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodeKinds:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Paper&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;direction: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;out&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;seeds:&lt;/span&gt;&lt;span&gt; [{ id: clip&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Paper&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;PPR-rank  score     global-rank  Δ    title&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;──────────────────────────────────────────────────────────────────&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;1    0.25884           17  +16  Learning Transferable Visual Models From Natural ...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;2    0.12804            1   -1  Learning representations by back-propagating errors&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;3    0.09396            5   +2  BERT: Pre-training of Deep Bidirectional Transfor...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;4    0.07889            4    ·  Attention Is All You Need&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;5    0.06382            3   -2  Efficient Estimation of Word Representations in V...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;6    0.05500            9   +3  Language Models are Unsupervised Multitask Learners&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;7    0.05500           15   +8  A Simple Framework for Contrastive Learning of Vi...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;8    0.05500           16   +8  An Image is Worth 16x16 Words: Transformers for I...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;CLIP itself jumps from 17th to 1st, since every jump lands back on it, and
SimCLR and ViT, both cited directly by CLIP and both well outside the global
top 10, climb eight places each. Changing only the seed gives you a ranking
for a different question, and it’s the one I’d reach for in a “related work”
or recommendation feature.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;do-research-communities-fall-out&quot;&gt;Do research communities fall out?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Label propagation finds communities by having every node adopt the most
common label among its neighbors, round after round, over the undirected
version of &lt;code dir=&quot;auto&quot;&gt;cites&lt;/code&gt;. Run it strictly first:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;converged&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;algorithms&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;labelPropagation&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;cites&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodeKinds:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Paper&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;onMaxIterations: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;// default&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;onMaxIterations: &quot;throw&quot; raised GraphAlgorithmConvergenceError —&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;the undirected citation graph oscillates (tree / even-cycle structure&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;that mirrors labels back and forth).&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That error is expected. In synchronous label propagation a node doesn’t vote
for itself, so a tree-shaped neighborhood (common once you flatten a citation
DAG into an undirected graph) can flip two labelings back and forth forever,
and more iterations won’t fix it. The default throws rather than handing you
whatever labels the last round happened to land on. If you want that
fixed-round answer, which is what the LDBC Graphalytics benchmark specifies,
ask for it:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;fixedRound&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;algorithms&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;labelPropagation&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;cites&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodeKinds:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Paper&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;onMaxIterations: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;3 communities:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;── community of 7 ──&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Adam: A Method for Stochastic Optimization            [Optimization]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Learning representations by back-propagating errors   [Optimization, DeepLearning]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Dropout: A Simple Way to Prevent Neural Networks ...   [DeepLearning, Optimization]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Gradient-Based Learning Applied to Document Recog...   [CNN, ComputerVision]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Sequence to Sequence Learning with Neural Networks     [RNN, NLP, DeepLearning]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Very Deep Convolutional Networks for Large-Scale ...   [CNN, ComputerVision]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Efficient Estimation of Word Representations in V...   [Embeddings, NLP]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;── community of 7 ──&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;BERT: Pre-training of Deep Bidirectional Transfor...   [Transformer, NLP, SelfSupervised]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Learning Transferable Visual Models From Natural ...   [Contrastive, MultiModal, ComputerVision]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Chain-of-Thought Prompting Elicits Reasoning in L...   [LanguageModel, Reasoning, NLP]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Language Models are Unsupervised Multitask Learners    [Transformer, NLP, LanguageModel]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;LLaMA: Open and Efficient Foundation Language Models   [Transformer, LanguageModel, NLP]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Attention Is All You Need                              [Transformer, Attention, NLP]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;An Image is Worth 16x16 Words: Transformers for I...   [Transformer, ComputerVision, DeepLearning]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;── community of 4 ──&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ImageNet Classification with Deep Convolutional N...   [CNN, ComputerVision, DeepLearning]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Momentum Contrast for Unsupervised Visual Represe...   [Contrastive, SelfSupervised, ComputerVision]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Deep Residual Learning for Image Recognition           [CNN, ComputerVision, DeepLearning]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;A Simple Framework for Contrastive Learning of Vi...   [Contrastive, SelfSupervised, ComputerVision]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The algorithm only saw undirected &lt;code dir=&quot;auto&quot;&gt;cites&lt;/code&gt; edges (the topic tags are printed
for you and were never fed to it), yet it separated the optimization and
classic-vision foundations, the transformer and language-model era, and the
contrastive self-supervised vision cluster purely from who cites whom.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;whats-still-missing&quot;&gt;What’s still missing&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Shortest path (weighted and unweighted), reachability, neighborhoods,
degree, connected components, label propagation, and global and personalized
PageRank cover a lot of ground, but strongly connected
components, topological sort, betweenness/closeness/eigenvector centrality,
and Louvain/Leiden community detection aren’t in &lt;code dir=&quot;auto&quot;&gt;store.algorithms&lt;/code&gt;. For
those, pull the edge list out with &lt;code dir=&quot;auto&quot;&gt;.query().traverse()&lt;/code&gt; or
&lt;code dir=&quot;auto&quot;&gt;store.subgraph()&lt;/code&gt; and hand it to an in-memory library like
&lt;a href=&quot;https://graphology.github.io/&quot;&gt;graphology&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Scale matters too. These run as rounds of SQL, which keeps everything in one
database and is fine for graphs the size most applications have, but on
millions of edges the engines that hold the graph in a specialized in-memory
index are orders of magnitude faster at whole-graph work. The
&lt;a href=&quot;https://typegraph.dev/blog/benchmarking-typegraph-neo4j-ladybugdb&quot;&gt;benchmark post&lt;/a&gt; has the
numbers, including the unflattering ones.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-algorithms&quot;&gt;Graph Algorithms&lt;/a&gt;: every algorithm, shared options,
temporal behavior, and PageRank tolerance notes across backends&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/examples/research-copilot&quot;&gt;Research Copilot&lt;/a&gt;: the same corpus, run
through the point-query algorithms&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph/blob/main/packages/typegraph/examples/32-graph-analytics.ts&quot;&gt;Example 32&lt;/a&gt;:
the runnable source behind this post&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Turning Two Agents&apos; Event Streams Into One Canonical Graph</title><link>https://typegraph.dev/blog/materializing-event-streams/</link><guid isPermaLink="true">https://typegraph.dev/blog/materializing-event-streams/</guid><description>Two CRM bots stream what they know about the same person into their own belief graphs. One crashes and resumes without a duplicate row, and entity resolution folds both streams into one canonical record.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Imagine a sales bot and a support bot that both talk to the same person
without knowing it. The sales bot knows her as “Jane Doe”, VP Engineering, at
&lt;code dir=&quot;auto&quot;&gt;jane@acme.com&lt;/code&gt;, while the support bot has “J. Doe”, VP Eng, at the same email,
along with two companies the sales bot has never heard of, one of which the
support bot retracts a day later. Both bots emit durable, at-least-once event
streams of what they’ve seen.&lt;/p&gt;
&lt;p&gt;Event logs are great at delivery, ordering, and replay, but they don’t resolve
entities, and you can’t ask a Kafka topic what an agent believed last Tuesday.
That work happens in a layer between the log and whatever reads it, which is
what
&lt;a href=&quot;https://typegraph.dev/materializing-event-logs&quot;&gt;Materializing Event Logs&lt;/a&gt; describes.
&lt;a href=&quot;https://github.com/nicia-ai/agent-stream-graph&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;@nicia-ai/agent-stream-graph&lt;/code&gt;&lt;/a&gt;
is the reference implementation, built on 0.35’s
&lt;code dir=&quot;auto&quot;&gt;store.transactionWithReceipt()&lt;/code&gt; and a couple of 0.36 additions. It pulls
together three things I’d built separately (bitemporal history, idempotent
writes, and graph merge), and seeing them click into one pipeline was a lot
of fun.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;projecting-events-idempotently&quot;&gt;Projecting events idempotently&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Streams redeliver changes after a crash, a reconnect, or a replay, so the
second delivery of a change has to land on the same row as the first. In
practice that means &lt;code dir=&quot;auto&quot;&gt;upsertById&lt;/code&gt; for nodes and &lt;code dir=&quot;auto&quot;&gt;getOrCreateByEndpoints&lt;/code&gt; for
edges, and a bare &lt;code dir=&quot;auto&quot;&gt;create&lt;/code&gt; only when the source event carries its own unique
id that you pass through as the TypeGraph id.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;project&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Projector&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;intelGraph&lt;/span&gt;&lt;span&gt;&gt; = async &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;belief&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;change&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;switch &lt;/span&gt;&lt;span&gt;(change&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;shape&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;case &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;person&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;if &lt;/span&gt;&lt;span&gt;(change&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;operation&lt;/span&gt;&lt;span&gt; === &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;delete&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;belief&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;delete&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;asNodeId&lt;/span&gt;&lt;span&gt;(change&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;key&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;return;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;belief&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;upsertById&lt;/span&gt;&lt;span&gt;(change&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;key&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;name: &lt;/span&gt;&lt;span&gt;change&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;value&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;email: &lt;/span&gt;&lt;span&gt;change&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;value&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;email&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;title: &lt;/span&gt;&lt;span&gt;change&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;value&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;return;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;case &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;employment&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;belief&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;edges&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;worksAt&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getOrCreateByEndpoints&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, id: &lt;/span&gt;&lt;span&gt;change&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;value&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;person&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Company&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, id: &lt;/span&gt;&lt;span&gt;change&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;value&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;company&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;return;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;resuming-after-a-crash&quot;&gt;Resuming after a crash&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;consume()&lt;/code&gt; checkpoints the last processed offset and a recorded-time anchor
after every change, and uses &lt;code dir=&quot;auto&quot;&gt;store.transactionWithReceipt()&lt;/code&gt; to know whether
the projector actually wrote anything. The demo runs the sales bot’s stream,
stops it after two of its four changes, then simulates the nastiest crash
window there is: a change gets projected, and the process dies before the
checkpoint lands.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;(b) Durable consumer — resume from checkpoint, replay safely after crash&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;consumer ran, then crashed after 2 messages&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;durable cursor: last offset = 002&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;sales-bot belief so far: 1 person, 1 company&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;crash window: projected 003, then died before checkpoint&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;uncheckpointed anchor existed: 2026-07-14T16:32:30.397Z&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;durable cursor is still:       002&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;belief already has worksAt edges: 1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;restarted — replayed 003, then processed 004 (2 messages)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;sales-bot belief now: 1 person, 1 company — Jane Doe (VP Eng &amp;#x26; Product)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;worksAt edges after replay: 1 (no duplicate edge)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;re-run (at-least-once): 0 messages processed; belief unchanged: 1 person, 1 company&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The cursor still says &lt;code dir=&quot;auto&quot;&gt;002&lt;/code&gt; even though &lt;code dir=&quot;auto&quot;&gt;003&lt;/code&gt; was already projected, which is
the crash window the demo sets up deliberately. On restart &lt;code dir=&quot;auto&quot;&gt;003&lt;/code&gt; replays,
&lt;code dir=&quot;auto&quot;&gt;getOrCreateByEndpoints&lt;/code&gt; finds the edge it already wrote, and processing
carries on. Running the whole stream a third time processes nothing because
the cursor is already at the end.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;rebuilding-from-offset-zero&quot;&gt;Rebuilding from offset zero&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A full rebuild is a different case: replay every event from the beginning
into a fresh belief graph, for recovery or a schema migration. Before 0.36,
that rewrote every row even when the replayed value matched what was already
there, so each rebuild cost a wasted write per event and grew recorded
history by the length of the log.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;createStore(graph, backend, { coalesceUnchangedUpserts: true })&lt;/code&gt; makes a
value-identical redelivery a true no-op that writes nothing, adds no history
row, and doesn’t advance the revision. A stream that actually changes a value
and later changes it back still writes both times, since those changes really
happened. 0.36
also adds &lt;a href=&quot;https://typegraph.dev/schemas-stores/#scoped-receipts-txmeasure&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;tx.measure()&lt;/code&gt;&lt;/a&gt;,
which scopes a receipt to the writes made by one callback, so a
materializer’s own cursor bookkeeping in the same transaction can’t be
mistaken for projector output.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-did-each-bot-believe-and-when&quot;&gt;What did each bot believe, and when?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Each bot’s belief graph has history enabled, so &lt;code dir=&quot;auto&quot;&gt;book.anchorFor(stream, offset)&lt;/code&gt; gives you a recorded-time coordinate for any processed offset. That
means you can read exactly what &lt;em&gt;this&lt;/em&gt; bot believed at &lt;em&gt;that&lt;/em&gt; point in its
own stream:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;(c) What did each agent believe, at which offset?&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;sales-bot&apos;s own belief graph:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;@offset 001: people: Jane Doe (VP Engineering) | companies: —&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;@offset 004: people: Jane Doe (VP Eng &amp;#x26; Product) | companies: Acme Corp (Series A)  (title corrected)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;support-bot&apos;s own belief graph (same person, different surface form):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;@offset 004: people: J. Doe (VP Eng) | companies: Umbrella (unverified), Acme (Series B), Globex (Series C)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;@offset 005: people: J. Doe (VP Eng) | companies: Acme (Series B), Globex (Series C)  (Umbrella retracted)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ Same email, but neither agent alone knows &apos;Jane Doe&apos; and &apos;J. Doe&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;are one person. The retracted company also remains visible in past belief.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The support bot’s belief at offset 4 still shows “Umbrella (unverified)”,
even though it was deleted at offset 5, because a read at a past offset
reconstructs the belief as it was then.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;one-canonical-graph&quot;&gt;One canonical graph&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Each bot’s belief exports through streaming interchange into a
&lt;a href=&quot;https://typegraph.dev/blog/graph-merge&quot;&gt;graph merge&lt;/a&gt; branch, and &lt;code dir=&quot;auto&quot;&gt;mergeIncremental()&lt;/code&gt; folds it
into a canonical graph. It’s the same entity resolution as in the graph merge
post, run once per stream as it arrives:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;importGraphStream&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;agentBranch&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;exportGraphStream&lt;/span&gt;&lt;span&gt;(belief, { includeTemporal: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt; }),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ onConflict: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;update&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;mergeIncremental&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;forkPoint&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;target: &lt;/span&gt;&lt;span&gt;canonical&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;branches:&lt;/span&gt;&lt;span&gt; [agentBranch]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;options: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;resolve: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Person: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;similarity: { kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;fulltext&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, fields:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;threshold: &lt;/span&gt;&lt;span&gt;0.9&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Company: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;similarity: { kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;fulltext&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, fields:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;threshold: &lt;/span&gt;&lt;span&gt;0.9&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;onPropertyConflict: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;flag&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;onBasePropertyConflict: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;flag&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;branchOrder:&lt;/span&gt;&lt;span&gt; [branchId]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;persistProvenance: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The sales bot merges first, as a clean append. The support bot merges
second, and that’s where the same-person, different-spelling problem gets
resolved:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[wave 1] merged sales-bot  — conflicts: 0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[wave 2] merged support-bot — conflicts: 4&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;conflict: Company.name on c1: support-bot=&quot;Acme&quot;; kept &quot;Acme Corp&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;conflict: Company.stage on c1: support-bot=&quot;Series B&quot;; kept &quot;Series A&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;conflict: Person.name on p1: support-bot=&quot;J. Doe&quot;; kept &quot;Jane Doe&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;conflict: Person.title on p1: support-bot=&quot;VP Eng&quot;; kept &quot;VP Eng &amp;#x26; Product&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;canonical now: 1 person, 2 companies — Jane Doe (VP Eng &amp;#x26; Product)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;provenance — sales-bot contributed to 3 canonical entities&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;provenance — support-bot contributed to 3 canonical entities&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;“Jane Doe” and “J. Doe” collapse into one canonical person, and every
disagreement is flagged instead of silently overwritten. Globex, which only
the support bot ever saw, joins as a new company. Umbrella, which the support
bot retracted before the merge, never shows up at all. And the canonical
graph has history too, so it time-travels across merge waves:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;canonical, time-travelled:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;asOfRecorded(after wave 1): 1 person, 1 company&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;asOfRecorded(after wave 2): 1 person, 2 companies&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;So from two unreliable streams you end up with one canonical graph, and you
can still ask any of the three graphs what it believed at any point along the
way.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-bigger-example&quot;&gt;A bigger example&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The demo above is small on purpose: one person, two bots, five offsets. The
repo’s main demo (&lt;code dir=&quot;auto&quot;&gt;pnpm demo&lt;/code&gt;) runs the same mechanics against a set of wiki
entries that mention overlapping concepts under different names, and
converges them into one canonical concept graph with source attribution for
every mention.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/materializing-event-logs&quot;&gt;Materializing Event Logs&lt;/a&gt;: idempotent
projectors, cursor bookkeeping, transaction receipts, and mapping event
time onto the two temporal axes&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-merge&quot;&gt;Graph Merge&lt;/a&gt;: the entity resolution this builds on&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/agent-stream-graph&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;agent-stream-graph&lt;/code&gt;&lt;/a&gt;:
&lt;code dir=&quot;auto&quot;&gt;pnpm demo&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;pnpm demo:mechanics&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>TypeGraph 0.35: Faster Almost Everywhere</title><link>https://typegraph.dev/blog/typegraph-0-35-performance/</link><guid isPermaLink="true">https://typegraph.dev/blog/typegraph-0-35-performance/</guid><description>Roughly 35 performance fixes across bulk writes, point reads, traversals, and search, plus a caching regression I shipped in 0.34 and fixed properly here.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A 2M-row bulk load into SQLite was still running after 4.5 hours with no sign
of finishing, and the cause turned out to be a statistics refresh. After a
large batch, &lt;code dir=&quot;auto&quot;&gt;bulkCreate&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;bulkInsert&lt;/code&gt; automatically run SQLite’s &lt;code dir=&quot;auto&quot;&gt;ANALYZE&lt;/code&gt;
so the query planner has fresh numbers, but that &lt;code dir=&quot;auto&quot;&gt;ANALYZE&lt;/code&gt; was bare and
unscoped: it scanned every table in the database file (not just TypeGraph’s),
with no limit, after every big batch. If you stream a load through repeated
&lt;code dir=&quot;auto&quot;&gt;bulkInsert()&lt;/code&gt; calls, each refresh costs more than the last and the total goes
quadratic.&lt;/p&gt;
&lt;p&gt;It’s now scoped to TypeGraph’s own tables and bounded with &lt;code dir=&quot;auto&quot;&gt;PRAGMA analysis_limit&lt;/code&gt;, the way the Postgres version already behaved. A 100k-row
reproduction of the same shape finishes in about 8 seconds, and the last
batch costs only about twice what the first one did.&lt;/p&gt;
&lt;p&gt;0.35 has about 35 fixes like that one, and below are the ones that matter
most, with numbers.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;bulk-writes&quot;&gt;Bulk writes&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;These four changes stack on top of each other for anything that writes a lot
of rows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SQLite pragmas at open.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;createLocalSqliteBackend&lt;/code&gt; now turns on
&lt;code dir=&quot;auto&quot;&gt;journal_mode=WAL&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;synchronous=NORMAL&lt;/code&gt;, and a busy timeout by default.
better-sqlite3’s own defaults pay a full fsync per write in
rollback-journal mode. Single-operation writes on file-backed databases are
roughly &lt;strong&gt;5x faster&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real bind-parameter limits.&lt;/strong&gt; The backend used to assume SQLite’s old
999-parameter limit on every driver. It now asks the driver (32,766 for
better-sqlite3, 100 for Cloudflare D1), so batches on better-sqlite3 use
&lt;strong&gt;~33x fewer statements&lt;/strong&gt;: 111-row chunks became 3,640-row chunks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;bulkCreate&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;bulkInsert&lt;/code&gt; batched end to end.&lt;/strong&gt; Existence checks,
uniqueness checks, and fulltext/embedding writes each happen once per batch
instead of once per row: &lt;strong&gt;~1,600 → ~4,100 rows/s (~2.6x)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;importGraph&lt;/code&gt; batched the same way: ~26k → ~96k entities/s (~4x).&lt;/strong&gt; The
default &lt;code dir=&quot;auto&quot;&gt;batchSize&lt;/code&gt; also went from 100 to 1,000, and now actually applies.
A schema-parsing gap meant the old default was silently ignored. That change
alone took a 20k-node, 5k-edge Postgres import from 1,515ms to 781ms.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There’s also a new &lt;code dir=&quot;auto&quot;&gt;walAutocheckpointPages&lt;/code&gt; option for tuning WAL checkpoints
during heavy loads. On its own it cut a 2M-row load’s time by more than half
at the largest scale tested.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-regression-and-fixing-it-properly&quot;&gt;The regression, and fixing it properly&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This one’s on me. 0.34 fixed a real correctness bug where “current” reads
compared against the &lt;em&gt;database’s&lt;/em&gt; clock instead of the &lt;em&gt;application’s&lt;/em&gt;, so
when the two clocks drifted (app and database on separate hosts, which is
normal), a row you’d just created could be invisible to the very next read.
The fix was to bind the read instant fresh every time a query compiled, which
was correct but had two problems I didn’t catch until this release.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It was slow.&lt;/strong&gt; Every &lt;code dir=&quot;auto&quot;&gt;execute()&lt;/code&gt; recompiled the query from scratch,
including the &lt;code dir=&quot;auto&quot;&gt;.prepare()&lt;/code&gt;-once, &lt;code dir=&quot;auto&quot;&gt;.execute()&lt;/code&gt;-many pattern that exists
specifically to avoid that. A repeated point query cost about 47µs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It was also, briefly, worse than the bug it fixed.&lt;/strong&gt; The query builders
cached their compiled SQL text across calls, so a prepared query froze “now”
at the moment it first compiled. Every row created after that had a later
&lt;code dir=&quot;auto&quot;&gt;valid_from&lt;/code&gt; than the frozen instant, and stayed invisible to that query
forever. It reproduced in a single process on the very next insert after
preparing a query, whereas the clock-skew bug at least needed two hosts.&lt;/p&gt;
&lt;p&gt;0.35 fixes both the same way: the SQL text is cached, and only the “now”
instant is re-bound as a parameter on each call. The repeated point query
went from &lt;strong&gt;47µs to 2.4µs (~20x)&lt;/strong&gt;, and a row created after &lt;code dir=&quot;auto&quot;&gt;.prepare()&lt;/code&gt;
shows up on the next &lt;code dir=&quot;auto&quot;&gt;.execute()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I’m not going to bury that in a changelog line. If you’re on 0.34 and use
&lt;code dir=&quot;auto&quot;&gt;.prepare()&lt;/code&gt;, upgrade.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;point-operations-and-traversals&quot;&gt;Point operations and traversals&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CRUD statements reuse the prepared-statement cache.&lt;/strong&gt; On synchronous
drivers, Drizzle’s &lt;code dir=&quot;auto&quot;&gt;db.all()&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;db.run()&lt;/code&gt; re-prepared every statement. They
now go through the same cached path the query engine uses. Single creates:
&lt;strong&gt;~18.3k → ~28.8k ops/s (~1.6x)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cascade deletes remove edges in batches&lt;/strong&gt; instead of one statement per
edge. A 50-edge cascade on local Postgres: &lt;strong&gt;24.4ms → 3.6ms&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;degree()&lt;/code&gt; can use the index again.&lt;/strong&gt; The direction filter compiled to a
shape neither edge index could seek. Now it matches the index prefix:
&lt;strong&gt;0.30ms → 0.06ms&lt;/strong&gt; on Postgres 18, and on Postgres 17 and earlier it no
longer falls back to scanning the whole partition.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subgraph extraction is ~4x faster on Postgres&lt;/strong&gt; (322ms → 82ms on a
depth-3 stress shape). The recursive traversal runs once instead of twice,
and the resulting ids go in as a single array parameter.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;search&quot;&gt;Search&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hybrid search is one SQL statement&lt;/strong&gt; instead of two searches plus fusion
in JavaScript, with the candidate filter computed once and shared. Filtered
hybrid search at 5k documents: &lt;strong&gt;26.5ms → 17.1ms&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Postgres fulltext can use its GIN index.&lt;/strong&gt; The query referenced the
language from a per-row column, which kept the planner off the index. It’s
now a constant: &lt;strong&gt;12.9ms → 2.3ms&lt;/strong&gt; at 5,000 documents.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exact &lt;code dir=&quot;auto&quot;&gt;.similarTo()&lt;/code&gt; on SQLite uses sqlite-vec’s KNN.&lt;/strong&gt; It’s brute force
in C, so results are identical, just faster: &lt;strong&gt;489ms → 124ms&lt;/strong&gt; for top-10
over 50k 384-dimension embeddings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Approximate vector search on Postgres uses the ANN index now.&lt;/strong&gt; A stray
&lt;code dir=&quot;auto&quot;&gt;DISTINCT&lt;/code&gt; kept the planner off the ordered index scan, and the inline path
wasn’t applying the same pgvector tuning as the search API. Together:
&lt;strong&gt;174ms → 2.1ms&lt;/strong&gt;, at 0.995 recall.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last area also had a correctness bug. Exact &lt;code dir=&quot;auto&quot;&gt;.similarTo()&lt;/code&gt; was
&lt;strong&gt;quietly approximate&lt;/strong&gt; whenever a matching ANN index existed, because
pgvector will happily answer an exact-looking &lt;code dir=&quot;auto&quot;&gt;ORDER BY ... LIMIT k&lt;/code&gt; from an
HNSW or IVFFlat index. Under a selective filter at 50k documents, measured
recall dropped as low as 0.000, which means the results were simply wrong.
The exact path now forces a true scan regardless of which indexes exist.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;found-by-benchmarking-against-real-graph-databases&quot;&gt;Found by benchmarking against real graph databases&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Two of these fixes came from running the LDBC Social Network Benchmark
against Neo4j and LadybugDB, not from profiling TypeGraph on its own. Edge
&lt;code dir=&quot;auto&quot;&gt;bulkCreate&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;bulkInsert&lt;/code&gt; had an N+1 endpoint check that made each batch
slower as the graph grew (~90ms → ~630ms per batch). And the default edge
traversal indexes were missing columns a join needed to stay index-only,
which didn’t show up until the table outgrew the page cache and then hit a
multi-second latency cliff. The
&lt;a href=&quot;https://typegraph.dev/blog/benchmarking-typegraph-neo4j-ladybugdb&quot;&gt;full benchmark writeup&lt;/a&gt;
covers where TypeGraph wins and where it doesn’t.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you’re upgrading an existing database&lt;/strong&gt;, note that the wider edge
indexes only appear on fresh databases. &lt;code dir=&quot;auto&quot;&gt;CREATE INDEX IF NOT EXISTS&lt;/code&gt; does
nothing when an index with that name exists, even with a different column
list, so an upgraded deployment keeps the narrow index until you rebuild it.
&lt;a href=&quot;https://typegraph.dev/performance/indexes&quot;&gt;Performance → Indexes&lt;/a&gt; has the exact &lt;code dir=&quot;auto&quot;&gt;DROP&lt;/code&gt; /
&lt;code dir=&quot;auto&quot;&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; steps for both backends.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;also-in-035&quot;&gt;Also in 0.35&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;.aggregate({...}).orderBy(key, direction?)&lt;/code&gt;&lt;/strong&gt;, so “top N groups by
count” no longer means fetching every group and sorting in JavaScript.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Breaking, ontology:&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;implies(edgeA, edgeB)&lt;/code&gt; now checks that the two
edges’ endpoint kinds are compatible. This also runs when a persisted
schema loads, so check saved schemas before rolling out, not just source.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/performance/overview&quot;&gt;Performance overview&lt;/a&gt; and
&lt;a href=&quot;https://typegraph.dev/performance/indexes&quot;&gt;Indexes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/changelog#0350&quot;&gt;Changelog&lt;/a&gt;: the full 0.35.0 entry&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Merging Two Feeds That Disagree About the Same Patient</title><link>https://typegraph.dev/blog/graph-merge/</link><guid isPermaLink="true">https://typegraph.dev/blog/graph-merge/</guid><description>Graph merge lets independent writers edit isolated branches of a store, then folds them back with deterministic entity resolution, repointed edges, flagged conflicts, and provenance.</description><pubDate>Tue, 09 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Point two ingestion agents at overlapping data (an EHR export and a claims
feed, say) and tell them to “just write everything to the graph”, and you end
up with two patient nodes for one person, each holding half the care history
and neither aware of the other. Most pipelines then add a nightly dedupe job
and hope nothing reads the graph in between.&lt;/p&gt;
&lt;p&gt;I think append is the wrong default for graphs, which is why 0.31 ships
&lt;code dir=&quot;auto&quot;&gt;@nicia-ai/typegraph/graph-merge&lt;/code&gt;. It’s the feature I’ve been most eager to get
into people’s hands. You branch a store, let each writer work on its own copy,
and then fold the branches back in: entities are resolved, edges are repointed
onto the surviving nodes, disagreements are reported instead of silently
overwritten, and the merge records who contributed what.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;branch-write-merge&quot;&gt;Branch, write, merge&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;branch()&lt;/code&gt; records the base store’s current state and hands back an
isolated working copy. Writers use the ordinary store API against it.
&lt;code dir=&quot;auto&quot;&gt;merge()&lt;/code&gt; diffs every branch against the base and runs one pipeline to fold
them back in:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;stage (diff every branch)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ generate candidates (exact unique · blocking key · similarity)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ cluster (group nodes that are the same entity)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ canonicalize (pick a survivor, union properties, resolve conflicts)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ repoint + dedupe edges onto survivors&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;          &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ reconcile delete/modify and types&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ commit transactionally + build the report&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The whole thing is deterministic: clusters resolve by stable keys and
conflicts are decided by an explicit &lt;code dir=&quot;auto&quot;&gt;branchOrder&lt;/code&gt; rather than by which branch
happened to arrive first, so merging the same branches in any order commits
the same graph.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;two-ways-to-be-the-same-patient&quot;&gt;Two ways to be the same patient&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph/blob/main/packages/typegraph/examples/18-fhir-graph-merge.ts&quot;&gt;Example 18&lt;/a&gt;
runs this on a small FHIR-flavored care graph. An EHR branch and a claims
branch each record the same two patients, and each gets both identities
wrong in a different way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Anna Rivera&lt;/strong&gt; (EHR) and &lt;strong&gt;Ana Rivera&lt;/strong&gt; (claims) share &lt;code dir=&quot;auto&quot;&gt;MRN-001&lt;/code&gt;, and
because &lt;code dir=&quot;auto&quot;&gt;mrn&lt;/code&gt; is declared &lt;code dir=&quot;auto&quot;&gt;unique&lt;/code&gt;, they’re matched regardless of how the
name is spelled, without any similarity threshold.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mohammed Ali&lt;/strong&gt; (EHR, &lt;code dir=&quot;auto&quot;&gt;MRN-204&lt;/code&gt;) and &lt;strong&gt;Mohamed Ali&lt;/strong&gt; (claims, &lt;code dir=&quot;auto&quot;&gt;MRN-205&lt;/code&gt;)
have &lt;em&gt;different&lt;/em&gt; MRNs, so nothing forces them together. They share a birth
date, which puts them in the same blocking bucket, and they collapse because
their fulltext name similarity clears the configured &lt;code dir=&quot;auto&quot;&gt;0.78&lt;/code&gt; threshold.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Landing in the same bucket only means two records get compared. The test
suite also covers the other side of the threshold with &lt;strong&gt;Zoe Adams&lt;/strong&gt; and
&lt;strong&gt;Quinn Webb&lt;/strong&gt;, who share a birth date and land in the same bucket but score
near zero on name similarity, so they stay two separate patients.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;mergeOptions&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MergeOptions&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;CareGraph&lt;/span&gt;&lt;span&gt;&gt; = {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;resolve: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Patient: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;block&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;node&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt; &lt;/span&gt;&lt;span&gt;node&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;birthDate&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;similarity: { kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;fulltext&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, fields:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;threshold: &lt;/span&gt;&lt;span&gt;0.78&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;onPropertyConflict: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;flag&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;branchOrder:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;EHR_BRANCH&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;CLAIMS_BRANCH&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;provenance: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;merge&lt;/span&gt;&lt;span&gt;(base&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; [ehr, claims]&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;mergeOptions);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Both pairs collapse to one canonical patient each:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;merged nodes: 9&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;merged edges: 10&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;entity resolutions: 2&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s six nodes staged on the EHR branch plus five on claims, minus the two
patients that folded into their counterparts, and all ten edges survive
without duplicates.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;disagreements-get-flagged-not-hidden&quot;&gt;Disagreements get flagged, not hidden&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Merge doesn’t quietly pick a value when branches disagree. The properties
that don’t agree are flagged, and the flags also show which identity mechanism
was in play:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;conflicts:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- Patient.fhirId on patient-ana: claims-agent=&quot;Patient/claims-ana&quot;, ehr-agent=&quot;Patient/ehr-anna&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- Patient.name on patient-ana: claims-agent=&quot;Ana Rivera&quot;, ehr-agent=&quot;Anna Rivera&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- Patient.fhirId on patient-mohamed: claims-agent=&quot;Patient/claims-mohamed&quot;, ehr-agent=&quot;Patient/ehr-mohammed&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- Patient.mrn on patient-mohamed: claims-agent=&quot;MRN-205&quot;, ehr-agent=&quot;MRN-204&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- Patient.name on patient-mohamed: claims-agent=&quot;Mohamed Ali&quot;, ehr-agent=&quot;Mohammed Ali&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;There’s no &lt;code dir=&quot;auto&quot;&gt;mrn&lt;/code&gt; conflict for &lt;code dir=&quot;auto&quot;&gt;patient-ana&lt;/code&gt;, because Anna and Ana share
&lt;code dir=&quot;auto&quot;&gt;MRN-001&lt;/code&gt; exactly. The Mohammed/Mohamed pair does flag &lt;code dir=&quot;auto&quot;&gt;mrn&lt;/code&gt;, since their
merge was based on name similarity and never required the MRNs to agree.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;every-edge-lands-on-the-survivor&quot;&gt;Every edge lands on the survivor&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is the part I like best: everything attached to either duplicate ends
up on the one canonical patient. Here it is read back through each survivor’s
&lt;code dir=&quot;auto&quot;&gt;forPatient&lt;/code&gt; edges rather than a table scan:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Ana Rivera (MRN-001, 1974-03-09)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- Encounter: Hypertension follow-up (2026-04-11T09:30:00-07:00)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- Encounter: Kidney function review (2026-04-14T10:00:00-07:00)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- MedicationRequest: Lisinopril 10 MG Oral Tablet - Take one tablet by mouth daily&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- Observation: Blood pressure panel = 152/96 mmHg (high)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- Observation: Estimated glomerular filtration rate = 54 mL/min/1.73m2 (low)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Mohamed Ali (MRN-205, 1990-08-21)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- Encounter: Cardiology consult (2026-05-02T13:00:00-07:00)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- Observation: LDL cholesterol = 168 mg/dL (high)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Ana Rivera’s five resources came from both branches (the EHR encounter and
medication, the claims encounter and lab result), and they now hang off a
single patient node that neither branch created on its own, so anyone
reading this record sees the full history. If an edge had been repointed
wrongly, it would be missing from the list.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;who-contributed-what&quot;&gt;Who contributed what&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;With &lt;code dir=&quot;auto&quot;&gt;provenance: true&lt;/code&gt;, the merge reports which branch contributed each
committed node and edge:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;provenance:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- ehr-agent: 6 node(s), 6 edge(s)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- claims-agent: 5 node(s), 4 edge(s)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That report lives only as long as the call. Pass &lt;code dir=&quot;auto&quot;&gt;persistProvenance: true&lt;/code&gt;
and each contribution is also written as a durable
&lt;code dir=&quot;auto&quot;&gt;{branch, sourceId} → canonical&lt;/code&gt; row in a separate provenance graph on the
same backend, so “what did this provider ever contribute?” is a query you
can run next month, without adding anything to your domain schema.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;merging-into-a-graph-that-kept-moving&quot;&gt;Merging into a graph that kept moving&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;merge()&lt;/code&gt; is a snapshot operation: every branch must fork from the target’s
&lt;em&gt;current&lt;/em&gt; state, or it fails with &lt;code dir=&quot;auto&quot;&gt;BaseVersionMismatchError&lt;/code&gt; rather than risk
clobbering newer data. That works when you fork, write, and merge in one
round, but real ingestion keeps going, and
&lt;a href=&quot;https://github.com/nicia-ai/typegraph/blob/main/packages/typegraph/examples/19-incremental-merge.ts&quot;&gt;Example 19&lt;/a&gt;
covers folding new batches into a target that has already moved on.&lt;/p&gt;
&lt;p&gt;In that example a company knowledge base already has &lt;code dir=&quot;auto&quot;&gt;Acme Corp&lt;/code&gt; (&lt;code dir=&quot;auto&quot;&gt;acme.com&lt;/code&gt;),
and a provider batch reports the same company under another spelling along
with one company that’s new:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Target before: [ &apos;Acme Corp (acme.com)&apos; ]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Target after:  [ &apos;Acme Corp (acme.com)&apos;, &apos;Globex (globex.io)&apos; ]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;No duplicate was created: the provider&apos;s &quot;ACME Corporation&quot; merged onto the&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;committed &quot;Acme Corp&quot; via the shared domain.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;mergeIncremental()&lt;/code&gt; finds the already-committed row by its unique &lt;code dir=&quot;auto&quot;&gt;domain&lt;/code&gt;
and merges onto it instead of creating a duplicate. It’s the same mechanism
as the shared-MRN case, matched against live data instead of another branch:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;mergeIncremental&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;forkPoint&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;// the frozen ancestor the branch forked from&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;target&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;// the live committed graph, which may have advanced&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;branches:&lt;/span&gt;&lt;span&gt; [provider]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;options: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;resolve: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Company: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;similarity: { kind: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;fulltext&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, fields:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;threshold: &lt;/span&gt;&lt;span&gt;0.9&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;onPropertyConflict: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;flag&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;onBasePropertyConflict: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;flag&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;// required: never let a stale branch value win&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;persistProvenance: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;onBasePropertyConflict: &quot;flag&quot;&lt;/code&gt; is required so that a stale branch can’t
overwrite something newer than the point it forked from. If the live target
changed the same row after the fork, the target’s value wins and the
disagreement is reported.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-merge&quot;&gt;Graph Merge&lt;/a&gt;: entity resolution, blocking, similarity
strategies, conflicts, scaling guards, and determinism&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/examples/fhir-graph-merge&quot;&gt;FHIR Graph Merge&lt;/a&gt; and
&lt;a href=&quot;https://typegraph.dev/examples/incremental-merge&quot;&gt;Incremental Graph Merge&lt;/a&gt;: the docs
walkthroughs&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph/blob/main/packages/typegraph/examples/18-fhir-graph-merge.ts&quot;&gt;Example 18&lt;/a&gt;
and
&lt;a href=&quot;https://github.com/nicia-ai/typegraph/blob/main/packages/typegraph/examples/19-incremental-merge.ts&quot;&gt;Example 19&lt;/a&gt;:
the runnable source behind this post&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>An Agent That Grows Its Own Schema</title><link>https://typegraph.dev/blog/runtime-schema-evolution/</link><guid isPermaLink="true">https://typegraph.dev/blog/runtime-schema-evolution/</guid><description>Graph extensions let a schema change be proposed at runtime, validated, and committed without a redeploy. I let a small model grow a clinical-research graph from real data, and it found retractions nobody designed for.</description><pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;If you follow a clinical trial through the literature, you see it registered
first, then published as a paper, and occasionally, years later, retracted. A
retraction notice is a kind of record that wasn’t in anyone’s schema when the
trial was registered, and usually wasn’t there when the paper was indexed
either.&lt;/p&gt;
&lt;p&gt;TypeGraph’s schema normally lives in code: you declare &lt;code dir=&quot;auto&quot;&gt;defineNode&lt;/code&gt; and
&lt;code dir=&quot;auto&quot;&gt;defineEdge&lt;/code&gt; in TypeScript, you get full type inference, and adding a new kind
of thing means a code change and a deploy. I think that’s the right default and
I’m keeping it, but it doesn’t fit an ingestion pipeline pulling from an API you
don’t control, a multi-tenant app where each tenant brings its own shape, or an
agent that discovers a new kind of record halfway through a corpus.&lt;/p&gt;
&lt;p&gt;0.25 adds &lt;strong&gt;graph extensions&lt;/strong&gt; for those cases. A schema change can be proposed
at runtime, validated as strictly as the compile-time path, and committed
atomically without a redeploy.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;proposing-a-change&quot;&gt;Proposing a change&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;An extension is a plain JSON document (new node and edge kinds, their property
types, unique constraints, indexes) built with &lt;code dir=&quot;auto&quot;&gt;defineGraphExtension&lt;/code&gt; and
committed with &lt;code dir=&quot;auto&quot;&gt;store.evolve()&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;proposal&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineGraphExtension&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodes: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Paper: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;properties: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;title: { type: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, minLength: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;doi: { type: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, minLength: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;year: { type: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;number&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, int: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;, min: &lt;/span&gt;&lt;span&gt;1900&lt;/span&gt;&lt;span&gt;, max: &lt;/span&gt;&lt;span&gt;2100&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;unique:&lt;/span&gt;&lt;span&gt; [{ name: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;paper_doi_unique&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, fields: [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;doi&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;] }]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;evolved&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;evolve&lt;/span&gt;&lt;span&gt;(proposal);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The type vocabulary is small on purpose: strings, numbers, booleans, enums,
and one level of array/object nesting. An LLM-written schema stays readable,
and it can’t smuggle in a Zod refinement or a function. A malformed proposal
throws &lt;code dir=&quot;auto&quot;&gt;GraphExtensionValidationError&lt;/code&gt; with per-field issues before anything
touches the database. The commit is checked against the active schema version,
so two writers racing to extend the same graph get a &lt;code dir=&quot;auto&quot;&gt;StaleVersionError&lt;/code&gt; to
retry on, not a silent overwrite.&lt;/p&gt;
&lt;p&gt;Reads get the same care. TypeScript can’t see a kind that didn’t exist at
compile time, so runtime kinds go through string-keyed versions of the query
builder, which check kind names against the live schema:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;rows&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;fromDynamic&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Paper&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;traverseDynamic&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;authoredBy&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;a&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;toDynamic&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Author&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;select&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ({ paper: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;, author: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt; }))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;execute&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A typo in a kind name throws &lt;code dir=&quot;auto&quot;&gt;KindNotFoundError&lt;/code&gt;, where a schemaless store
would quietly return zero rows and leave you to find out later.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;letting-an-agent-drive&quot;&gt;Letting an agent drive&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A toy example is fine for the API, but I wanted to know whether the loop holds
up against messy, real, multi-stage data. So I built
&lt;a href=&quot;https://github.com/pdlug/typegraph-clinical-demo&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;typegraph-clinical-demo&lt;/code&gt;&lt;/a&gt;:
the same machinery end to end, over real trial registrations from
ClinicalTrials.gov, their publications from PubMed, and retractions and
corrections from CrossRef. It’s all public bibliographic metadata, with no
patient data.&lt;/p&gt;
&lt;p&gt;An LLM agent watches the corpus arrive in three stages and proposes an
extension after each one. The proposals are &lt;strong&gt;blind&lt;/strong&gt;: the agent sees sample
records and the names of kinds already in the graph, and I didn’t give it any
hints about types, searchable fields, or which identifier should be unique.
Property types, optionality, searchability, and constraints are all inferred
from the samples.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stage 1, registration.&lt;/strong&gt; The agent sees about 1,100 ClinicalTrials.gov
records and proposes &lt;code dir=&quot;auto&quot;&gt;ClinicalTrial&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stage 2, publication.&lt;/strong&gt; PubMed records referencing those trials arrive, and
the agent proposes &lt;code dir=&quot;auto&quot;&gt;Publication&lt;/code&gt; with a &lt;code dir=&quot;auto&quot;&gt;referencesTrial&lt;/code&gt; edge back to stage 1.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stage 3, retraction.&lt;/strong&gt; Retraction notices and corrections arrive. Nobody
designs for these up front, because most trials never get one. The agent
proposes &lt;code dir=&quot;auto&quot;&gt;PublicationEvent&lt;/code&gt; with a &lt;code dir=&quot;auto&quot;&gt;correctsPublication&lt;/code&gt; edge back to stage 2.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each stage is a real &lt;code dir=&quot;auto&quot;&gt;store.evolve()&lt;/code&gt; against a real store, followed by a bulk
ingest under the new schema.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-the-proposal-is-valid-but-wrong&quot;&gt;When the proposal is valid but wrong&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Validation catches malformed proposals, but it can’t catch one that’s
internally consistent and still wrong for the data, like a field the agent marked
required because the three samples it saw all happened to have it. So after
each accepted proposal the demo runs a &lt;strong&gt;smoke test&lt;/strong&gt;: ingest the full sample
into a scratch store seeded with every earlier extension. Failures go back to
the agent in the same structured &lt;code dir=&quot;auto&quot;&gt;{path, code, message}&lt;/code&gt; form validation uses.&lt;/p&gt;
&lt;p&gt;Stage 3 is where it fires, on a real run:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;STAGE 3: post-publication discourse&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;agent  attempt=1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;validator  ACCEPTED  +nodes=[PublicationEvent]  +edges=[correctsPublication]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;smoke test  FAILED  2 issue(s) — proposal validates but doesn&apos;t fit the data:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[INGEST_INVALID_TYPE] artifactDoi: expected string, received undefined&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[INGEST_INVALID_TYPE] date: expected string, received undefined&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;agent  attempt=2&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;validator  ACCEPTED  +nodes=[PublicationEvent]  +edges=[correctsPublication]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;smoke test  PASSED  schema fits the sample&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;PublicationEvent nodes: 922/922 ingested  (0 skipped)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The first proposal saw three samples, all &lt;code dir=&quot;auto&quot;&gt;correction&lt;/code&gt; events with
&lt;code dir=&quot;auto&quot;&gt;artifactDoi&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;date&lt;/code&gt; filled in, and made both required. The full-sample
ingest hit &lt;code dir=&quot;auto&quot;&gt;comment&lt;/code&gt; events without them and failed. On attempt 2 the agent
made both optional, the smoke test passed, and the graph committed. The whole
stage, repair included, takes about 16 seconds and one extra model call.&lt;/p&gt;
&lt;p&gt;I love watching this loop work. Nobody explained the mistake to the model in
prose; it got the same machine-readable errors a developer would have seen and
fixed its own schema. Because the smoke test runs against a scratch store, a failed attempt
leaves no stray rows or half-claimed unique keys for the next attempt to trip
over. Only a proposal that survives gets committed to the real store.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-payoff&quot;&gt;The payoff&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;After stage 3 the graph has three kinds and two edges that didn’t exist when
the demo started, and one query walks all of them:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;rows&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;fromDynamic&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;PublicationEvent&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;event&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;traverseDynamic&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;correctsPublication&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;correction&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;toDynamic&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Publication&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;pub&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;traverseDynamic&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;referencesTrial&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;reference&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;toDynamic&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;ClinicalTrial&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;trial&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;select&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;event: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;event&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;publication: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;pub&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;trial: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;trial&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;execute&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;On the real corpus it surfaces four documented retraction chains, including
SCIPIO (cardiac stem cells, Lancet 2011, retracted 2019) and a 2018 nilotinib
trial outcome, plus three more the corpus turned up on its own: the Anil Potti
genomic-predictor case and the Mehra et al. hydroxychloroquine retraction that
halted multiple registered trials in 2020 among them. Getting there took no
migrations, just three &lt;code dir=&quot;auto&quot;&gt;store.evolve()&lt;/code&gt; calls and a query written against kind
names that were plain strings until the agent defined them.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;you-dont-need-a-frontier-model-for-this&quot;&gt;You don’t need a frontier model for this&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The demo includes an eval harness (&lt;code dir=&quot;auto&quot;&gt;pnpm eval&lt;/code&gt;) that runs the same blind
three-stage pipeline across a lineup of models and scores whether each first
proposal survives validation and the smoke test. The cheapest model that
cleared all three stages on the first try was a 26B-parameter open-weight MoE
with 4B active parameters. It came in 4x cheaper than Gemini 3.1 Flash Lite
(which also went three for three) and 5x cheaper than GPT 5.4 nano (which
needed one repair).&lt;/p&gt;
&lt;p&gt;The full three-stage run, repair included, takes under 30 seconds and costs
about &lt;strong&gt;$0.003&lt;/strong&gt; in API calls. The loop needs a structured error channel and a
model that can read a JSON Schema error and try again, and when the schema
layer does its job, a small model handles that fine.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/graph-extensions&quot;&gt;Graph Extensions&lt;/a&gt;: the full reference&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/examples/agent-driven-schema&quot;&gt;Agent-Driven Schema&lt;/a&gt;: a minimal, in-repo
version of the same loop&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/pdlug/typegraph-clinical-demo&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;pdlug/typegraph-clinical-demo&lt;/code&gt;&lt;/a&gt;:
clone it, &lt;code dir=&quot;auto&quot;&gt;pnpm demo&lt;/code&gt;, and watch the schema grow&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Embeddings Can&apos;t Find a SKU</title><link>https://typegraph.dev/blog/hybrid-search/</link><guid isPermaLink="true">https://typegraph.dev/blog/hybrid-search/</guid><description>TypeGraph now has native BM25 fulltext search and hybrid retrieval with Reciprocal Rank Fusion, on SQLite and Postgres, with no search service to run.</description><pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Type &lt;code dir=&quot;auto&quot;&gt;PROD-1005-E&lt;/code&gt; into a search box and you expect one result: the product
with that SKU. Vector search is bad at this in a way tuning won’t fix: a rare
alphanumeric code barely moves an embedding, so cosine similarity ranks on
everything &lt;em&gt;else&lt;/em&gt; in the query, and the product whose exact code you typed
ends up under a pile of vaguely similar ones.&lt;/p&gt;
&lt;p&gt;BM25 handles it easily, because it counts terms and a token that appears in
exactly one document wins however odd it looks. Rather than trying to make
embeddings better at exact matches, I wanted both signals fused in one place,
so 0.21 added native fulltext search and hybrid retrieval that combines it
with vector search, and 0.24 finished the job on SQLite.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;fulltext-is-a-field-modifier&quot;&gt;Fulltext is a field modifier&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Mark a field &lt;code dir=&quot;auto&quot;&gt;searchable()&lt;/code&gt; and TypeGraph keeps a BM25 index in sync on every
write. On Postgres that’s &lt;code dir=&quot;auto&quot;&gt;tsvector&lt;/code&gt; + GIN; on SQLite it’s FTS5. You don’t
need an Elasticsearch cluster for this, or a sync job to feed one.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;Product&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineNode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Product&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schema: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;name: &lt;/span&gt;&lt;span&gt;searchable&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ language: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;english&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;description: &lt;/span&gt;&lt;span&gt;searchable&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ language: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;english&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;sku: &lt;/span&gt;&lt;span&gt;searchable&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ language: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;english&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;category: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;enum&lt;/span&gt;&lt;span&gt;([&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;outerwear&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;footwear&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;accessories&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;climbing&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;])&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;embedding: &lt;/span&gt;&lt;span&gt;embedding&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;16&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;optional&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Query it with &lt;code dir=&quot;auto&quot;&gt;store.search.fulltext()&lt;/code&gt;, or use &lt;code dir=&quot;auto&quot;&gt;$fulltext.matches()&lt;/code&gt; as a
predicate inside an ordinary query, where it combines with metadata filters
and traversals in the same SQL statement:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;activeOuterwear&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Product&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;whereNode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;$fulltext&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;matches&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;lightweight&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;and&lt;/span&gt;&lt;span&gt;(p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;eq&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;active&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;and&lt;/span&gt;&lt;span&gt;(p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;category&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;eq&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;outerwear&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;select&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ({ sku: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sku&lt;/span&gt;&lt;span&gt;, name: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt; }))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;execute&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;hybrid-fused-with-rrf&quot;&gt;Hybrid, fused with RRF&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;store.search.hybrid()&lt;/code&gt; runs the vector search and the fulltext search and
merges them with Reciprocal Rank Fusion. RRF only looks at rank positions, so
it doesn’t care that a cosine score and a BM25 score live on completely
different scales, which makes it the least fiddly fusion method I know of.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;hybridHits&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;search&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;hybrid&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Product&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;limit: &lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;vector: { fieldPath: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;embedding&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;queryEmbedding&lt;/span&gt;&lt;span&gt;, metric: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;cosine&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, k: &lt;/span&gt;&lt;span&gt;20&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;fulltext: { query: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;waterproof shell&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, k: &lt;/span&gt;&lt;span&gt;20&lt;/span&gt;&lt;span&gt;, includeSnippets: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;fusion: { method: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;rrf&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, k: &lt;/span&gt;&lt;span&gt;60&lt;/span&gt;&lt;span&gt;, weights: { vector: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, fulltext: &lt;/span&gt;&lt;span&gt;1.25&lt;/span&gt;&lt;span&gt; } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Fulltext worked on both backends from 0.21, but hybrid needs the backend to
run a vector search, which SQLite couldn’t do yet, so on SQLite the hybrid call
threw &lt;code dir=&quot;auto&quot;&gt;ConfigurationError&lt;/code&gt;. 0.24 gives SQLite a real vector search on top of
&lt;code dir=&quot;auto&quot;&gt;sqlite-vec&lt;/code&gt;, built to the same shape as the Postgres version, and the hybrid
call now takes the same options and returns the same results on both.&lt;/p&gt;
&lt;p&gt;SQLite also turned out to be the faster of the two. On the project’s search
benchmark (500 documents, 384 dimensions), SQLite hybrid runs in &lt;strong&gt;0.8ms&lt;/strong&gt;
against Postgres’s 2.5ms, partly because SQLite runs in-process and doesn’t
pay for a round trip.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;running-it&quot;&gt;Running it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph/blob/main/packages/typegraph/examples/15-fulltext-hybrid-search.ts&quot;&gt;Example 15&lt;/a&gt;
seeds nine outdoor-gear products (parkas, shells, a climbing harness, ski
goggles), each with a searchable name, description, and SKU, plus a small
embedding. A BM25 query for &lt;code dir=&quot;auto&quot;&gt;&quot;waterproof jacket&quot;&lt;/code&gt; finds the Expedition Parka,
and the snippet shows why:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Heavily insulated &amp;#x3C;mark&gt;jacket&amp;#x3C;/mark&gt; for alpine expeditions and extreme&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cold. &amp;#x3C;mark&gt;Waterproof&amp;#x3C;/mark&gt; outer shell with down fill.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Then the SKU:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Query: &quot;PROD-1005-E&quot; (looking up an exact SKU)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;1. [PROD-1005-E] Climbing Harness Pro     score=1.6328&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;It comes back as the only result, where a pure vector search on that string
ranks unrelated products higher because the SKU barely registers in the
embedding.&lt;/p&gt;
&lt;p&gt;Hybrid is where it gets interesting. Here’s &lt;code dir=&quot;auto&quot;&gt;&quot;waterproof shell&quot;&lt;/code&gt; with &lt;code dir=&quot;auto&quot;&gt;k=20&lt;/code&gt; on
each side, RRF &lt;code dir=&quot;auto&quot;&gt;k=60&lt;/code&gt;, and fulltext weighted at &lt;code dir=&quot;auto&quot;&gt;1.25&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;1. [PROD-1001-A] Expedition Parka         score=0.0357 (v#3, f#3)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;2. [PROD-1002-B] Arctic Shell             score=0.0356 (v#6, f#1)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;3. [PROD-9901-Z] Legacy Rain Shell        score=0.0355 (v#5, f#2)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;4. [PROD-1007-G] Compression Socks        score=0.0164 (v#1, f—)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;5. [PROD-1008-H] Hiking Daypack 25L       score=0.0161 (v#2, f—)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;(v#, f#)&lt;/code&gt; tags are each hit’s rank on each side. Arctic Shell was
fulltext’s top pick but only sixth by vector, so pure vector search would have
buried it, and RRF lifts it to second because doing well on &lt;em&gt;either&lt;/em&gt; side
counts. Compression Socks went the other way: they were the vector side’s top
hit, but with no fulltext match at all they end up at the bottom of the list.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;it-composes-with-everything-else&quot;&gt;It composes with everything else&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The same fusion is on the query builder as &lt;code dir=&quot;auto&quot;&gt;.fuseWith()&lt;/code&gt;, so a hybrid search
can carry ordinary predicates. Filtering to &lt;code dir=&quot;auto&quot;&gt;status = &quot;active&quot;&lt;/code&gt; drops the
discontinued Legacy Rain Shell in the same query, without a post-filter:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;builderHybrid&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Product&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;whereNode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;$fulltext&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;matches&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;waterproof shell&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;20&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;and&lt;/span&gt;&lt;span&gt;(p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;embedding&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;similarTo&lt;/span&gt;&lt;span&gt;(queryEmbedding, &lt;/span&gt;&lt;span&gt;20&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;and&lt;/span&gt;&lt;span&gt;(p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;eq&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;active&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;fuseWith&lt;/span&gt;&lt;span&gt;({ k: &lt;/span&gt;&lt;span&gt;60&lt;/span&gt;&lt;span&gt;, weights: { vector: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, fulltext: &lt;/span&gt;&lt;span&gt;1.25&lt;/span&gt;&lt;span&gt; } })&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;select&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ({ sku: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sku&lt;/span&gt;&lt;span&gt;, name: ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt; }))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;limit&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;execute&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// → Arctic Shell, Expedition Parka, Hiking Daypack 25L, Ski Goggles UV400,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;//   Compression Socks&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;There are four query modes for what a search box actually receives:
&lt;code dir=&quot;auto&quot;&gt;websearch&lt;/code&gt; for Google-style syntax (&lt;code dir=&quot;auto&quot;&gt;&quot;ski goggles&quot; -compression&lt;/code&gt;), &lt;code dir=&quot;auto&quot;&gt;phrase&lt;/code&gt;
for exact adjacency, &lt;code dir=&quot;auto&quot;&gt;plain&lt;/code&gt; for all-terms-must-match, and &lt;code dir=&quot;auto&quot;&gt;raw&lt;/code&gt; when you want
the engine’s native &lt;code dir=&quot;auto&quot;&gt;tsquery&lt;/code&gt; or FTS5 &lt;code dir=&quot;auto&quot;&gt;MATCH&lt;/code&gt; syntax.&lt;/p&gt;
&lt;p&gt;And if the index falls behind (you added a &lt;code dir=&quot;auto&quot;&gt;searchable()&lt;/code&gt; field, or a bulk
write went around the store), &lt;code dir=&quot;auto&quot;&gt;store.search.rebuildFulltext()&lt;/code&gt; backfills it
page by page:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Before rebuild: &quot;waterproof&quot; → 0 hits (fulltext rows cleared).&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Rebuilt: kinds=Product processed=9 upserted=9 cleared=0 skipped=0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;After rebuild: &quot;waterproof&quot; → 3 hits restored.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/fulltext-search&quot;&gt;Fulltext Search&lt;/a&gt;: RRF tuning, adding fulltext to
existing data, and alternate Postgres strategies (pg_trgm, ParadeDB,
pgroonga)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/semantic-search&quot;&gt;Semantic Search&lt;/a&gt;: the vector half&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph/blob/main/packages/typegraph/examples/15-fulltext-hybrid-search.ts&quot;&gt;Example 15&lt;/a&gt;:
the runnable source behind this post&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item><item><title>Introducing TypeGraph</title><link>https://typegraph.dev/blog/introducing-typegraph/</link><guid isPermaLink="true">https://typegraph.dev/blog/introducing-typegraph/</guid><description>A typed knowledge graph that lives inside your TypeScript app, is defined by one Zod schema, and stores everything in the SQLite or Postgres you already run.</description><pubDate>Sat, 21 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every project I’ve worked on that needed real structure (a knowledge base, an
org chart, memory for an agent) ended up with the same stack: an ORM for the
actual data, a vector store added when someone wanted semantic search, and
eventually, once the relationships got interesting, a graph database off to
the side with a sync job keeping it roughly in line with the other two.&lt;/p&gt;
&lt;p&gt;Each of those systems has its own idea of what a &lt;code dir=&quot;auto&quot;&gt;Person&lt;/code&gt; is and its own
consistency model, so the schema drifts between them. Meanwhile TypeScript
already has Zod, a schema language good enough to describe all of it, and none
of those systems treats it as the source of truth.&lt;/p&gt;
&lt;p&gt;TypeGraph is the other option: keep the graph inside the application, as a
library, and store it in the database you already run.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;it-runs-in-your-process&quot;&gt;It runs in your process&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;TypeGraph writes through your existing SQLite or Postgres connection and
commits in the same transaction as the rest of your data. You don’t deploy a
graph server or keep one running, and your app doesn’t make a network hop to
reach its own graph.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;one-schema&quot;&gt;One schema&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;You describe your data once, in Zod:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineNode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schema: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ name: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;, role: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;worksOn&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineEdge&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;worksOn&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schema: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ since: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;graph&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineGraph&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;org&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nodes: { Person: { type: &lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt; } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;edges: { worksOn: { type: &lt;/span&gt;&lt;span&gt;worksOn&lt;/span&gt;&lt;span&gt;, from:&lt;/span&gt;&lt;span&gt; [Person]&lt;/span&gt;&lt;span&gt;, to:&lt;/span&gt;&lt;span&gt; [Person]&lt;/span&gt;&lt;span&gt; } },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;From that one definition TypeGraph derives runtime validation, the TypeScript
types, the storage layout, and what the query builder will let you write. If
you’ve ever kept an ORM schema, a folder of hand-written interfaces, and a
Cypher cheat sheet in sync by hand, you know why I wanted this.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;relationships-that-mean-something&quot;&gt;Relationships that mean something&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A foreign key tells you two rows are related, but it can’t tell you that a
&lt;code dir=&quot;auto&quot;&gt;Podcast&lt;/code&gt; is a kind of &lt;code dir=&quot;auto&quot;&gt;Media&lt;/code&gt;, that &lt;code dir=&quot;auto&quot;&gt;marriedTo&lt;/code&gt; implies &lt;code dir=&quot;auto&quot;&gt;knows&lt;/code&gt;, or that a
&lt;code dir=&quot;auto&quot;&gt;Person&lt;/code&gt; and an &lt;code dir=&quot;auto&quot;&gt;Organization&lt;/code&gt; can never be the same thing. In most codebases
those rules live in a comment, or in the head of whoever wrote the migration.&lt;/p&gt;
&lt;p&gt;In TypeGraph, edges are first-class and typed, and they carry their own
properties. The ontology layer (&lt;code dir=&quot;auto&quot;&gt;subClassOf&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;implies&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;disjointWith&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;equivalentTo&lt;/code&gt;) does real work: a query for &lt;code dir=&quot;auto&quot;&gt;Media&lt;/code&gt; can include podcasts, a
&lt;code dir=&quot;auto&quot;&gt;knows&lt;/code&gt; traversal can pick up spouses, and a write that would make a person
and an organization share an identity fails. Traversals compile to SQL, so a
three-hop walk is one query rather than a loop of lookups.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;vectors-are-just-another-field&quot;&gt;Vectors are just another field&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Embeddings are a field type. When you declare one, TypeGraph stores and
indexes it on whichever backend you’re running:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineNode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;schema: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ title: &lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;, embedding: &lt;/span&gt;&lt;span&gt;embedding&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;1536&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;similar&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;whereNode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; d&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;embedding&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;similarTo&lt;/span&gt;&lt;span&gt;(queryVector, &lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;select&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;execute&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;.similarTo()&lt;/code&gt; is a predicate like any other, so it composes with filters and
traversals in the same query. You don’t ask a vector database for a list of
IDs and then run a second query to find out what those IDs are.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-isnt&quot;&gt;What it isn’t&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;TypeGraph isn’t trying to be Neo4j. There’s no PageRank, no community
detection, no distributed storage, and at this stage no traversal algorithms
beyond the queries you write yourself. It’s built for thousands to millions
of nodes living next to the rest of your data. If your graph &lt;em&gt;is&lt;/em&gt; the product
and it has billions of edges, you want a dedicated graph database.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;where-it-stands&quot;&gt;Where it stands&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;It’s early, a handful of releases in. The DSL, the ontology layer, vector
search, and both backends are solid. What comes next depends on what people
build with it, so if you try it and hit a wall, open an issue.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/overview&quot;&gt;What is TypeGraph?&lt;/a&gt; and the &lt;a href=&quot;https://typegraph.dev/getting-started&quot;&gt;Quick Start&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/ontology&quot;&gt;Ontology &amp;#x26; Reasoning&lt;/a&gt;: &lt;code dir=&quot;auto&quot;&gt;subClassOf&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;implies&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;disjointWith&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;equivalentTo&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://typegraph.dev/semantic-search&quot;&gt;Semantic Search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nicia-ai/typegraph&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt;&lt;div&gt;&lt;h2&gt;Stay in the loop&lt;/h2&gt;&lt;p&gt;Occasional updates on new features, guides, and releases. No spam.&lt;/p&gt;&lt;form id=&quot;newsletter-form&quot;&gt;&lt;input type=&quot;email&quot; name=&quot;email&quot; required placeholder=&quot;you@example.com&quot; autocomplete=&quot;email&quot;&gt;&lt;!-- Honeypot: hidden from real users, filled by bots --&gt;&lt;input type=&quot;text&quot; name=&quot;website&quot; autocomplete=&quot;off&quot; tabindex=&quot;-1&quot; aria-hidden=&quot;true&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;renderedAt&quot; id=&quot;rendered-at&quot;&gt;&lt;button type=&quot;submit&quot;&gt;Subscribe&lt;/button&gt;&lt;/form&gt;&lt;p id=&quot;newsletter-message&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/section&gt;</content:encoded><category>announcements</category></item></channel></rss>