Changelog
Release notes for @nicia-ai/typegraph. Generated from packages/typegraph/CHANGELOG.md on every build.
0.52.0
Section titled “0.52.0”Minor Changes
Section titled “Minor Changes”-
#558
48532f8Thanks @pdlug! - Eligible schema-managed generated-id node creates andcardinality: "many"edge creates on bundled root backends can now execute as one authoritative statement, including Neon HTTP and Cloudflare D1, where all required claims, projections, and side effects are either absent or fused into that statement. History/revision and Operational Identity work, plus other managed writes, continue to require the interactive transaction or an explicit typed refusal.Clarify and harden execution boundaries for managed writes. The authoritative command helper now validates command/result correlation once, with typed node, edge, and convergence overloads; first-party Store consumers no longer duplicate that check, while recorded-capture retains its direct transaction-wrapper assertion.
OptionalTransactionExecutionis now a discriminated{ mode: "interactive-transaction" | "sequential" }value; migrate custom consumers fromexecution.atomictoexecution.mode.Document the distinction between interactive Store transactions, static internal adapter batches, and authoritative one-statement commands. Durable edge
matchIdentityconvergence may qualify for the one-statement root command because its canonical key has a database arbiter; claims/cardinality, undeclared dynamicmatchOn, history/revision sidecars, and Operational Identity remain interactive-transaction contracts. The static native-batch adapter foundation remains internal; no new public Store batching API is implied. -
#556
d2c2557Thanks @pdlug! - Replace the optional managed-create hook with a required semantic command port that carries its root or transaction session and, only after an advisory graph lock is acquired, a graph- and session-bound coordination token. On PostgreSQL that lock statement records the effective transaction isolation in the same token, so convergence never trusts a requested option or assumed server default and adds no isolation-probe round trip. TransparentderiveBackendcommand wrappers retain the underlying session identity; a wrapper for another connection cannot reuse its token. Under that lock, PostgreSQL endpoint get-or-create folds the match-key read, endpoint validation, and insert into one statement, returning either the created edge or the existing winner without another application-level read. Adopted transactions may return an existing match at any isolation, but the create leg refuses repeatable read.Breaking change and migration: custom
GraphBackendimplementations must add acommandsmember with{ session, execute(command, context) }. Move managed-create and specialized edge-insert behavior into thenode.create,edge.create, andedge.converge-createcommand cases, and return the typedunsupportedresult for dimensions the backend cannot apply. The optional combined-fence hooklockSchemaVersionAndGraphWritenow returnsPromise<GraphCommandIsolation>instead ofPromise<void>; custom implementations must return the normalized effective isolation observed by the same pinned-session statement that acquires both locks. Built-in adapters already provide this port. -
#554
ddf851dThanks @pdlug! - Introduce authoritative node create commands and reduce first-party PostgreSQL write round trips by folding schema and graph fences, uniqueness and disjointness verdicts, endpoint and cardinality checks, and generated fulltext/vector projections into atomic statements. Managed Store transactions now lease one schema fence across their writes without sacrificing the fused first statement, and endpoint get-or-create decisions are confirmed from transaction-scoped evidence on caching transports.The backend planning API now uses one required semantic command port for node and edge creates. Commands carry an explicit root or transaction session and, when an advisory graph lock was actually acquired, a graph- and port-bound coordination token; custom backends implement that same contract rather than silently falling back to a second decision path.
Breaking change and migration: add
commands: { session, execute }to customGraphBackendobjects and route node/edge create plans through it. A backend that cannot honor a requested plan dimension must return its typedunsupportedresult; it must not silently ignore the dimension. See the authoritative command sessions section of the backend setup guide. -
#554
ddf851dThanks @pdlug! - Replace the three specialized edge-insert backend hooks with the shared semantic command port. Managed edge creates now compile endpoint validation, an optional schema fence, and an optional cardinality claim into one all-or-nothingedge.createcommand with an explicit result. Custom backends implement the required command contract and must apply or refuse every requested dimension.Breaking change and migration: custom backends should remove their old specialized edge-insert hook wiring and implement
commands.executeforedge.create(andedge.converge-createwhen convergence is supported). Return the typedunsupporteddimension result when endpoint, schema-fence, cardinality, or convergence behavior is unavailable. -
#561
cdcb814Thanks @pdlug! - Execute eligible durable-match and cardinality-constrainededges.bulkInsertandedges.bulkCreatecalls as one schema-fenced atomic exchange on bundled Neon HTTP, Cloudflare D1, and libSQL roots. The program maintains stale-claim takeover and legacy-incumbent detection, preserves typed endpoint, match-identity, and cardinality refusals, and rolls back every edge row when any constraint sidecar fails. -
#557
6799b65Thanks @pdlug! - Add graph-local durable edge match identities. An edge registration can declare one named, canonical property-field set; TypeGraph persists its directed endpoint/property key on every edge row, maintains it across normal and trusted import writers, refuses ordinary updates to identity fields, retains it across soft deletion, and releases it on hard deletion. SQLite and PostgreSQL provision and idempotently upgrade the edge relation with a pair-null check and unique arbiter.Schema-managed root
getOrCreateByEndpointscalls using a declared identity now compile endpoint validation, the schema fence, conflict arbitration, and the created/found result into one database statement on bundled SQLite and PostgreSQL backends. Dynamic call-levelmatchOnremains available through the transaction-fenced compatibility path, and a supplied field list on a declared edge must exactly match the declaration. Bulk endpoint candidate reads use the set-oriented heterogeneous endpoint member instead of one read per endpoint pair on bundled backends.Direct creates use the same durable arbiter at every cardinality. Built-in bulk creates preserve set-oriented insertion through a conflict-arbitrated batch command rather than falling back to one managed write per row.
Operation-end hooks now report
outcome: "written" | "unchanged" | "unknown". An authoritative get-or-create command that finds an incumbent completes as"unchanged"and does not fireonError; the same explicit outcome prevents revision/history churn for the no-write leg. Commands without an authoritative physical-write verdict report"unknown"instead of guessing from success.Normal import uses the same set-oriented durable command for claimless slices and savepoint-protected batch recovery for exceptional conflicts, including on history-enabled stores. Non-transactional backends refuse ambiguous per-row retry after a failed batch rather than re-inserting a possibly committed prefix.
Adding, removing, or changing a match identity is a breaking schema change. The initial migration contract refuses activation while the affected edge kind holds rows; export and hard-delete those rows, migrate the schema, then import them so every row receives the new durable key.
-
#539
2dcae2fThanks @pdlug! - Add durable schema-only graph templates with idempotent v1 instantiation. -
#560
da56b5fThanks @pdlug! - Execute eligible unconstrainededges.bulkInsertandedges.bulkCreatecalls as one schema-fenced native atomic exchange on bundled Neon HTTP, Cloudflare D1, and libSQL roots. The closed program validates live endpoints at the write boundary, preserves input-order results, and rolls back every bind-budget chunk when any statement fails. Cardinality claims, durable match identity, history, revision tracking, caller-owned transactions, derived backends, and unproven custom backends retain the existing transaction or fallback path.Cast closed-program CTE values to their destination column types so PostgreSQL accepts JSON and temporal values in both node and edge native batches.
-
#559
ff42eb4Thanks @pdlug! - Execute eligible schema-managed, generated-IDnodes.bulkInsertbatches as one schema-fenced native atomic exchange on bundled Neon HTTP, Cloudflare D1, and libSQL roots. This first closed-program slice excludes claims, Operational Identity, projections, history, revision, and caller-supplied IDs; unsupported shapes retain their existing transaction or fallback path.Skip the guaranteed-empty existence-priming read for generated-ID bulk node operations while preserving caller-ID existence and resurrection checks.
Patch Changes
Section titled “Patch Changes”0.51.1
Section titled “0.51.1”Patch Changes
Section titled “Patch Changes”- #526
dbe8ee6Thanks @pdlug! - Read persisted unique constraints that omitscopeorcollationby applying the documented"kind"and"binary"defaults. Schema-management APIs can now inspect databases written with those omitted fields instead of reporting a malformed schema document.
0.51.0
Section titled “0.51.0”Minor Changes
Section titled “Minor Changes”-
#521
da251efThanks @pdlug! - Custom backend capabilities now resolve through six shared bundles instead of scatteredundefinedchecks. This makes each operation family consistently choose one of three outcomes: use the declared member, take its documented fallback, or refuse with a typed error.The pilot covers
claims,statementExecution,recordedRevisionOrigins,batchPointRead,uniqueSidecarBatch, andcontributionHealth. Batch point reads and supported sidecar operations degrade to their existing per-item implementations when the batch member is absent. Operations without a safe fallback keep their existing typed refusal. A backend whose capability declaration disagrees with the members reachable on its execution port now refuses withCONSTRAINT_CLAIM_SURFACE_MISMATCHfor claims orBUNDLE_PORT_SURFACE_MISMATCHfor the other bundles.CAPABILITY_BUNDLES, the six named definitions, their verdict and binding types, and theresolveBundle,bindCore,bindExtra, andbindExtraIfReachablehelpers are public for backend conformance tooling. Both bundled backends already implement every required member, so their behavior is unchanged.This pilot covers six of twenty-one member-bearing operation families; the other fifteen continue to work through their existing paths. See Capability bundles for the complete member and fallback matrix.
-
#520
b6478f6Thanks @pdlug! -drizzle-ormis now optional when using TypeGraph’s portable entrypoints. Applications that import only the root, backend, core, schema, indexes, graph-extension, interchange, profiler, graph-merge, or provenance entrypoints no longer need Drizzle installed.Applications using a managed SQLite or PGlite Store, or an explicit
/adapters/drizzle/...entrypoint, must still install it withnpm install drizzle-ormwhen their package manager does not install optional peers automatically. Managed Store factories report a typedMISSING_PEER_DEPENDENCYerror with that command; explicit Drizzle adapters retain the runtime’s raw module-resolution error. See Managed Store Entrypoints and installation troubleshooting. -
#520
b6478f6Thanks @pdlug! - Portable entrypoints no longer reach Drizzle through recorded-time migration, claim comparison, or removal-statement builders. This completes the separation that lets consumers use the ten portable entrypoints without installingdrizzle-orm; source and packaged-output checks now prevent those imports from returning.Custom backends that need to migrate the timestamp-only recorded-time preview schema must implement the new optional
GraphBackend.recordedTableDdl(tableNames)member. It returns backend-owned table and index DDL for the temporary and final recorded-relation names. A migration that reaches the legacy rewrite without this member throwsUnsupportedBackendCapabilityErrorwithdetails.capability: "recordedTableDdl"instead of importing Drizzle or crashing. See Migrating Preview Recorded Time. -
#521
da251efThanks @pdlug! - Custom backends can now declare whether they support recursive traversal withcapabilities.recursiveTraversal. Absence means supported for backward compatibility; an engine without recursive SQL or a graph-native equivalent declares{ supported: false, reason }.The decision is resolved through a branded
RecursiveTraversalVerdictthat onlyresolveRecursiveTraversalcan construct, so a caller cannot forge one by writing{ supported: true }inline. Also exported:assumeRecursiveTraversalSupported(the one sanctioned way to obtain a verdict without a backend, used by the query compiler’s no-backend entry point),assertRecursiveTraversal,recursiveTraversalUnsupportedError, and theRecursiveTraversalCapabilitytype itself.Variable-length queries,
store.subgraph(), and the three recursion-dependent historical identity reads now refuse an unsupported declaration withConfigurationErrorcodeRECURSIVE_TRAVERSAL_UNSUPPORTED;details.operationanddetails.reasonidentify the affected path and engine limitation.weightedShortestPathkeeps working when temporary statements are available, reconstructing the same path throughpathLength + 1predecessor reads instead of one recursive extraction statement.CompileQueryOptionsgains an optionalrecursiveTraversal, threaded bypropagateOptionsinto every set-operation sub-compile so aunion()/intersect()/except()operand carries the same verdict as its parent query.Bundled factories refuse contradictory declarations — unsupported without a reason or supported with a dangling reason — using
CAPABILITY_DECLARATION_CONTRADICTION. The bundled SQLite and PostgreSQL backends declare support, so their query behavior is unchanged.Custom backend note: the factory-owned clone of
backend.capabilitiesis now deep-frozen, so mutating it after construction throws. Objects supplied through factory options remain caller-owned and mutable. See Recursive traversal capability and the recursive query guide. -
#521
da251efThanks @pdlug! - Custom backend migration: a backend not built bycreateSqliteBackendorcreatePostgresBackendmust now declarecapabilities.pessimisticLocksbefore hosting Operational Identity,history: true, orrevisionTracking: true. Store construction refuses an undeclared backend immediately instead of risking an unfenced concurrent write. PostgreSQL backends normally declarepessimisticLocks: { advisoryLocks: true, tableLocks: true, serializedWriters: false }; SQLite backends normally declarepessimisticLocks: { advisoryLocks: false, tableLocks: false, serializedWriters: true }. Verify those values against the engine’s actual guarantees rather than copying them for a different topology.BackendCapabilitiesgains an optionalpessimisticLocksfield ({ advisoryLocks, tableLocks, serializedWriters }) declaring how an engine serializes concurrent writers, if at all.resolveWriteFencePlanis the one place that declaration turns into aWriteFencePlan(lock/engine-serialized/unfenced) every lock site now consumes instead of re-deriving fromdialectinline, andrequireWriteFenceis the one place an operation’s specific lock requirement ("advisory-lock"/"table-lock") is checked against the resolved plan, refusing withWRITE_FENCE_UNAVAILABLEwhen it cannot be met. This consolidates eight call sites that used to spell the same dialect-keyed decision independently.BackendCapabilitiesalso gains an optionalrecordedTimeOwnershipfield ("typegraph-relations"|"engine-native") naming who allocates recorded-time revisions. Absent means"typegraph-relations"— today’s behavior for every existing backend. Declaring"engine-native"together withhistory/revisionTrackingis refused at construction withENGINE_NATIVE_RECORDED_TIME_NOT_IMPLEMENTEDas an interim measure, independently of the write-fence plan, because the engine-native read/write path does not exist yet; a later release lifts this refusal with that path.Both bundled backends already declare their write-fence support, so shipped configurations keep their existing behavior. See Write fence declaration, recorded-time ownership, and the stable error codes.
Patch Changes
Section titled “Patch Changes”- #521
da251efThanks @pdlug! - For contributors: CI now compares the publicetc/*.api.mdsnapshots with the last published tag throughtest:api-surface. The check fails when an external consumer would lose a member, see an optional member become required, or need to supply a newly required member through a contravariant API position. This adds no runtime or published API; it makes breaking surface changes visible before release. See the release verification commands.
0.50.0
Section titled “0.50.0”Minor Changes
Section titled “Minor Changes”-
#490
02c0370Thanks @pdlug! - Issue a node’s claims on the side of the row write their placement names, and refuse the writes a backend with no transactions cannot undo.A uniqueness claim whose axis spans kinds beyond the writer’s own is the only fence for that axis — the nodes primary key is
(graph_id, kind, id), so anEmployee’s insert does not collide with aContractor’s — and a fence issued after the write it fences is not a fence. Those claims now precede the row insert they gate, with the reservations given back if that insert does not land, so a refusal leaves zero net effect. On the import path this is what turns a violation into a refusal instead of a committed row:importGraphrecovers per row and takes no per-graph lock, so a claim written after the row it was supposed to refuse let the row commit.A claim whose axis is the writer’s own kind keeps the position it has today, after the row: the uniques primary key at that axis is already the complete fence for it, moving it would buy nothing, and it would cost a refusal on backends with no transactions. Placement is decided once, per claim, from the one fact both readings turn on — does this claim’s axis span kinds beyond the writer’s own? — and carried as data through the entry, the claim seam, the refusal and the lock projection. Which writes take the per-graph advisory lock, and the reason each reports, are unchanged.
Two new refusals follow, both on
transactions: falsebackends (Cloudflare D1,drizzle-orm/neon-http,transactionMode: "none"SQLite), and bothConfigurationError/CONSTRAINT_WRITE_FENCE_UNSUPPORTED:importGraph/importGraphStreaminto a graph any of whose node kinds declares a unique constraint of any scope, or any of whose edge kinds is non-many. Import writes claim rows like every other writer but is not covered by the write-transaction refusal, so it would have written reservations with nothing to roll them back. The refusal is computed up front, before the first chunk, so a streamed import cannot commit k-1 chunks and then fail. Disjointness owes no claim yet — that fence lands in a later batch — so a disjoint-only graph is not refused here.- A node UPDATE or RESURRECT whose kind declares only
scope: "kind"unique constraints, reasonnodeUniquenessClaim. This closes an existing hole rather than paying for a new one: the transition seam already claims before its gated row write for every scope, so that path already wrote a reservation with nothing to undo it. The matching create is not refused — its claim stays after the row — which is the pair that makes the rule legible: same kind, same constraint, opposite verdicts, decided only by placement.
ConstraintFenceReasongainsnodeUniquenessClaimfor that refusal. It is never returned by the lock projection, so it cannot widen the set of writes that take the per-graph lock.Claim statements within each placement group are issued in one canonical order — code-point on
(relation, graph, axis, constraint, key)— and the pre-insert group is always issued first, so two writers touching the same claim rows for one row acquire them in the same order instead of deadlocking. For a node create this is observable as statement order: a kind owing only own-axis claims emits exactly what it emits today, a kind owing a cross-kind claim emits it ahead of the row insert, and a kind owing both emits two claim statements, one on each side. -
#490
02c0370Thanks @pdlug! - Tolerate the concurrentCREATE EXTENSIONrace when materializing trigram indexes, and give every extension install one owner.method: "trigram"needspg_trgm, the extension is database-global, and the claim that serializes an index build is keyed per index — so two materializers building different trigram indexes both reachCREATE EXTENSION IF NOT EXISTS pg_trgm. That statement is not a concurrency primitive on PostgreSQL: its existence check cannot see another session’s uncommittedpg_extensionrow, so the loser waited for the winner and was handed SQLSTATE 23505 instead of a notice, reportingfailedfor an extension the winner had already installed.GraphBackendgains an optionalensureExtension(name)member — the single owner of “install a database-global extension idempotently” — which the bundled PostgreSQL backend implements with both fences: a transaction advisory lock keyed on the extension, so same-key installers never raise at all, and the concurrent-DDL retry its table and column creates already use, which clears the 23505 an installer that did NOT take that lock can still hand it (a peer on an older version, or acapabilities.transactions: falsebackend with no transaction to hang the lock on). The name is validated against the exportedDATABASE_EXTENSION_NAMESallowlist rather than interpolated freely.GraphBackend.ensureTrigramExtension, thepg_trgm-only member added in 0.47, is deprecated in favour ofensureExtensionand now says exactly the same thing: the bundled PostgreSQL backend implements it by delegating, and index materialization consults it only afterensureExtension, so a backend written against 0.47 keeps its fence unchanged. A backend implementing neither keeps issuing the bare statement with materialization’s own one-shot retry, so a third-party trigram index is still materialized. The advisory-lock key changed fromtypegraph:pg-trgm-ddltotypegraph:extension-ddl:<extension>when the fence generalized to any allowlisted extension — a 0.47 peer therefore takes a different key, which is exactly why the retry is retained on the locked path too.Closes #446.
-
#490
02c0370Thanks @pdlug! - FencedisjointWithwith a claim on the declared pair, and enforce it underimportGraph.Disjointness was probed and never fenced. The nodes primary key is
(graph_id, kind, id), soPerson "X"andCompany "X"are two different rows by construction — the exact collision the axiom forbids is the one the database cannot refuse — and the probe was only as good as the serialization around it.importGraphtakes no per-graph lock and, until now, ran no disjointness probe at all: an import could commit both halves of a violating pair.A create of a kind with a
disjointWithpartner now reserves one claim row per partner, in the same relation uniqueness claims use, at the pair’s own axis with the node’s id as the key. Both kinds of a pair fold to one axis through the registry’s own canonical pair label, so their claims contend for one row and its primary key refuses the second writer. The claim precedes the row it gates and is given back if that row does not land, so a refusal leaves zero net effect. Because the two families arrive through one list of claim sites, every path that already maintained uniqueness reservations — create, batch create, delete, import — maintains disjointness reservations too. A resurrect — a soft-deleted node revived by.create()on its tombstoned id,upsertById,upsertByIdFromRecord,bulkUpsertById, orgetOrCreateByConstraint— reserves the same claim, since reviving a tombstone re-introduces a live id under a kind exactly as a create does; the resurrect leg no longer has a window where it could revive a node under an id a disjoint partner already holds live.importGraphgains the per-row disjointness probe both node paths were missing, and the per-row recovery it sits in is widened fromUniquenessErrorto every declared-constraint refusal. Behavior deltas:- Import now enforces disjointness. A payload containing a
Personand aCompanywith the same id, in one batch or in sequence, refuses the second row and reports it inerrorswhile the import continues — the accepted rows commit. Previously both committed silently. A concurrent violation, taken by another writer between this row’s probe and the batch’s claim, still surfaces from the claim and aborts the import; that asymmetry is what import already does for uniqueness. importGraph/importGraphStreamis refused on atransactions: falsebackend when any node kind has a disjoint partner, joining the unique-constraint and non-many-cardinality cases (ConfigurationError/CONSTRAINT_WRITE_FENCE_UNSUPPORTED). A disjoint create’s claim precedes its row, and without a transaction a failure between the two would leave a reservation with no repair path.- A kind or unique constraint name containing
U+001Eis refused atdefineNode/defineGraph. That code point builds the axes that are not kinds, so a name carrying it could spell the reserved disjointness axis. New refusal on an input no real schema carries.
The refusal a caller sees is the family’s own
DisjointError, with the same payload whichever layer produced it: the probe reads the partner’s node row, the claim reads its reservation’s owner, and both name the holder’s concrete kind. - Import now enforces disjointness. A payload containing a
-
#490
02c0370Thanks @pdlug! - Fence edge cardinality with a claim relation, and enforce it underimportGraph.Declared edge cardinality was probed and never fenced.
oneandoneActiveare predicates over(kind, from)anduniqueis one over(kind, from, to), while the edges relation’s only uniqueness is its(graph_id, id)primary key — so two writers could both count zero sibling edges and both commit, and nothing in the schema re-decided at write time.importGraphmade it worse by running no cardinality probe at all: a payload could commit any number of edges acardinality: "one"declaration forbids.A new relation,
typegraph_edge_claims, keyed(graph_id, axis, key), is the fence. Each constrained edge write reserves the axis its declaration spans (<cardinality>:<edgeKind>) against the endpoint identity the declaration covers, in two statements: a decision-free create-or-lock that reports the committed holder, then — only when the holder is a different edge — a conditional takeover that succeeds exactly when that holder is no longer an edge the axis and key describe. Deciding inside one upsert would read the pre-lock snapshot of the edges relation under READ COMMITTED and accept both writers; the split is what makes the second one lose.The claim needs no release path. A holder that is soft-deleted, hard-deleted, or (for
oneActive) ended fails the takeover’s liveness predicate and is replaced in place, so no delete, end, cascade or kind-removal path participates in the fence. The holder is identified by its kind and source endpoints as well as its id, because edge ids are caller-suppliable: a reused id would otherwise read as a live holder and block its axis forever.EDGE_CARDINALITY_SPECSis the one table both the TypeScript probe and the takeover’s SQL read for which endpoints an axis covers, whether an edge born already ended claims at all, and what a holder must still be — so the probe and the fence cannot drift apart.Behavior deltas:
- Import now enforces edge cardinality (
one/unique/oneActive). Two edges from one source in one payload refuse the second row and report it inerrorswhile the import continues; the accepted rows commit. Import’s edge slice reuses the store’s own in-batch cardinality accounting to make that per-row rather than a whole-slice abort. A concurrent violation, taken by another writer between a row’s probe and the slice’s claim, still surfaces from the claim and aborts the import — the same asymmetry import already has for uniqueness. PostgresTableNames/SqliteTableNames/SqlTableNamesgainedgeClaims, andBackendCapabilitiesgains an optionalconstraintClaims. Absent meansfalse: a backend that predates the claim relations keeps every fence it has today and is never refused for the absence. Both bundled dialects declaretrueand implement every claim member. A backend whose declaration and surface disagree in either direction is refused withConfigurationError/CONSTRAINT_CLAIM_SURFACE_MISMATCHrather than silently unfenced.GraphBackendgains optionalclaimEdgeCardinality,claimEdgeCardinalityBatchandpurgeEdgeClaims. Additive; a custom backend that omits them declaresconstraintClaims: falseand keeps working.- A database bootstrapped before this release needs the new table. It is emitted by the existing idempotent boot path and by
generatePostgresMigrationSQL/generateSqliteMigrationSQL. A store reaching a missing relation on its first constrained edge write is refused with a typedConfigurationError(EDGE_CLAIM_RELATION_MISSING) naming the relation and the migration to run, instead of an opaque driver failure.
The refusal a caller sees is the family’s own
CardinalityError, built by the same functions the probe calls, so it is indistinguishable from the serial refusal it replaces. - Import now enforces edge cardinality (
-
#490
02c0370Thanks @pdlug! - Scope uniqueness-claim releases to the node that owns the claim.A
typegraph_node_uniquesrow records both the axis it fences on (node_kind) and the node that owns it (concrete_kind,node_id). Releases keyed on the axis alone could not tell those apart: a soft delete gave up whatever row sat at the node’s own kind, and a kind removal deleted every claim whose axis was the removed kind. Both readings are wrong the moment an axis and a concrete kind differ — they leak a claim that blocks its key forever, and they delete a surviving sibling’s claim.Release now has three explicitly different shapes, each with one owner: a lifecycle release gives up every claim the node holds for a constraint and key at whatever axis it sits on (soft delete, an update’s key-change release, the resurrect diff); a compensating release undoes exactly the row a failed write claimed, at the axis it claimed on; and kind reaping removes every claim the removed kind’s nodes own, through the new
buildHardDeleteUniquesByConcreteKindbuilder thatmaterializeRemovalsand the new optionalhardDeleteUniquesByConcreteKindbackend member both compile.DeleteUniqueParamsgains the owner pairconcreteKind/nodeId, and itsnodeKindbecomes optional — present selects the compensating shape, absent the lifecycle one. A third-partyGraphBackendthat implementsdeleteUniquemust make BOTH changes: addconcrete_kindandnode_idto its predicate, and make thenode_kindterm conditional onparams.nodeKindbeing present. Doing neither does not leave the old behavior in place: the lifecycle release now passes nonodeKind, so a predicate that still spellsnode_kind = :nodeKindunconditionally compares against NULL, matches zero rows, and releases nothing — every soft delete and every key-change release leaks its claim, and the key stays blocked forever. TypeScript cannot catch it either, since anundefinedbound into a SQL template is accepted silently. -
#490
02c0370Thanks @pdlug! - Fencescope: "kindWithSubClasses"uniqueness on a shared claim axis, and decide claim ownership by(concrete_kind, node_id).A shared-scope unique constraint used to reserve its key under the writer’s OWN kind, while the probe walked the whole hierarchy. Sibling kinds therefore reserved rows that could never collide — the
typegraph_node_uniquesprimary key was structurally incapable of refusing the second writer, and only the per-graph lock stood between two concurrent creates and a duplicate (#436). The claim is now written at the scope’s axis: the code-point minimum of the connectedsubClassOfcomponent, which every kind in that component computes identically, so two writers of two kinds contend for one row and the primary key is the fence. Under multiple inheritance or multiple roots this is stricter than before — the component is what the old “walk one root’s descendants” reading was documented to mean — and the probe still visits every kind in scope, so rows written before the upgrade are still read and no data migration is required.Ownership of a claim is the pair
(concrete_kind, node_id), not the id alone. Ids are unique only per kind, soEmployee "X"andContractor "X"are two different nodes; comparing ids let the second one match the “this row is already mine” arm of the upsert, rewrite the incumbent’sconcrete_kind, and read its own id back as proof it had won. Both upsert builders now compare the pair and return it, the accept/refuse test compares the pair, and the batch-validation cache remembers the pair — so a live claim held by a namesake under another kind is a refusal where it was previously a silent takeover. In one import batch, that refusal is now reported per row (with the earlier rows committed) instead of aborting the whole batch at the flush.Two payload/behavior corrections come with it.
UniquenessError.kindnow names the holder’s own kind rather than thenode_kindthe row was found under, which is the same value on a single-kind scope and the meaningful one on a shared scope. And the cross-kind lookups behindfindByConstraintandgetOrCreateByConstraintstate their preference explicitly — axis first, then the remaining kinds in code-point order, live rows preferred over tombstoned ones — so a database carrying both a pre-upgrade and a post-upgrade row for one key resolves deterministically instead of by iteration order.The per-graph write lock is unchanged: which writes take it, and the reason each one reports, are byte-identical, now derived from the same claim-site classification the claim itself is written from.
-
#490
02c0370Thanks @pdlug! - Addstore.verifyConstraintFences(), the read-only audit of constraint violations that predate the fence.The claim relations refuse the second live claimant of an axis from the first write after upgrade onward, but they repair nothing that is already there. A database that carried two live siblings sharing a
scope: "kindWithSubClasses"key, an id live under both kinds of adisjointWithpair, or two livecardinality: "one"edges from one source keeps carrying them: the next write that touches such an axis is refused with the ordinary typed error naming the incumbent, and until then nothing says so. This is the diagnostic that says so.It reads the relation each constraint is declared over, never a claim relation’s primary key. A claim key admits one row per axis by construction, and a database written before the claim tables existed holds no edge claims at all, so a claim scan would report zero violations on precisely the data the audit exists to find. Uniqueness is read from the live
uniquesrows and folded onto the axis each row’snode_kindbelongs to — which is how a pre-upgrade duplicate sitting at two differentnode_kinds is found at all — restricted to constraint names the graph declares, so disjointness claims (whosenode_kindis a pair label, not a kind) are audited from the nodes relation instead. Contention is counted in distinct owner pairs (concrete_kind,node_id), not rows, so one node legitimately holding its key at a legacy axis and at the current one is not reported.Each entry names the claim row two claimants contend for — built by the same functions the fence writes with — plus the conflicting
owners(uniqueness, disjointness) oredgeIds(cardinality). It writes nothing and repairs nothing: choosing which claimant keeps an axis is a data-loss decision that belongs to the operator.GraphBackendgains an optionalreadConstraintFenceViolations. Additive; both bundled dialects implement it through one shared statement per family.store.verifyConstraintFences()refuses withConfigurationError/CONSTRAINT_FENCE_AUDIT_UNSUPPORTEDon a backend without it, rather than returning an empty report a caller would read as “clean”.KindRegistrygainsdisjointKindPairs(), the declared pairs as kind pairs — the inverse of the internal pair label, so an enumerating caller never spells the label’s form itself.
The parity matrix in
backend-setup.mdgains the three rows this mechanism owes a reader: theconstraintClaimscapability, PostgreSQL’s40001in place of the typed error above READ COMMITTED, and the claim row’s lock being held to end-of-transaction on both dialects — refusal included, so a caller that catches a constraint error and continues blocks other writers of that axis for the rest of its transaction.
Patch Changes
Section titled “Patch Changes”-
#490
02c0370Thanks @pdlug! - Restore a merged node’sdisjointWithreservations after a resolved merge write set commits.A resolved node write set (the graph-merge apply path, and the set update it shares its preflight with) validates the whole after-image, then clears the affected nodes’ sidecar rows so its upserts can take the approved keys in any order, then rebuilds them once at the end — the rebuild is what keeps a coalesced, otherwise side-effect-free upsert from leaving its key unreserved.
The clear is keyed on the claim’s OWNER, so it takes every reservation the affected nodes hold, and since 0.48 that includes their
disjointWithclaims as well as their uniqueness claims. The rebuild now goes through the same claim writer an ordinary create uses, which restores whatever the row’s kind owes rather than the uniqueness slice alone; previously a merged node came out of every merge with its disjointness axis unreserved, leaving it unfenced against a disjoint namesake for the rest of the graph’s life. -
#491
2009e6cThanks @pdlug! - Close the write-pipeline seam: the row-work read projection is now the type every write path actually uses. The preparation helpers, constraint and uniqueness probes, batch validation caches and identity hooks the migrated modules reach are re-typed offGraphBackend | TransactionBackendonto the narrow handle a write frame hands out, so the countedunfencedTargetwidening falls from seventeen call sites to one. That one is structural rather than migration debt — the bulkgetOrCreateByEndpointslegs re-enter the executor against their enclosing frame’s target, and re-entry mints a session — and the ratchet now records it as a reasoned floor with a second escape failing the build.Three seams are stated instead of implied along the way.
IdentityTargetis an explicit facet composition of what an identity statement needs (reads plus the optional raw-statement port) rather than the whole backend union, with the service context’sbackendnamed for what it is: the handle the service opens its own write frames on.ConstraintContextand the uniqueness probe carry read facets, which states in the type that no check in either module writes. The executor’s overlaid-session mint takes the READS to answer rather than a backend to write through, so row work can no longer hand the session an arbitrary backend.src/store/operations/index.tspublishes the seam (runWritePlan,WritePlan,WriteSession) and still re-exports no step or sidecar module, and the write-pipeline files come off knip’s ignore list. No public API, behavior, error type, statement or lock scope changes. -
#491
2009e6cThanks @pdlug! - Route every edge write through the write pipeline: the rawinsertEdge/updateEdge/deleteEdge/hardDeleteEdgecalls move into a newedge-write-pipeline.tsstep module and the insert dispatch, reached through the session’s seven edge methods under an edge write plan. All nine edge entry points — including the bulkgetOrCreateByEndpointsbatch — now declare their constraint probe as plan data instead of spelling it at the transaction call, and an edge update states its asserted identity and validity bound as a fence record whose keys are required, so a partially stated fence is a type error rather than a silently unfenced write. The zero-row diagnosis (withUnmatchedEdgeUpdateRefusal) moves toedge-write-fences.tsand stays caller-applied, because the store’s converge-or-refuse reading and interchange import’s report-and-continue reading are genuinely different recovery policies. No public API, behavior, error type, statement, or lock scope changes. -
#491
2009e6cThanks @pdlug! - Route interchange import through the write pipeline: its hand-built write context and its ownrunInWriteTransactioncall are gone, and all six write legs — the batched and per-row node creates, the node update, the batched and per-row edge creates, and the edge update — now run as session calls under one write plan whose identity participation the executor acquires. Import’s hand-rolledinsertNodesBatch === undefined/insertEdgesBatch === undefinedprobes converge on the insert dispatch that already owns that decision, and its edge update states the five immutable identity components and the window guard’s stored lower bound as a fence record with required keys instead of a spread convention. The write-pipeline exemption list has no migration debt left: every remaining entry is a step, sidecar or reasoned carve-out. No public API, behavior, error type, statement or lock scope changes. -
#491
2009e6cThanks @pdlug! - Route every node write except the set update through the write pipeline: the eight managed entry points innode-operations.tsnow compose a write plan and run through the executor, and their row and sidecar writes are the session’s fused units rather than hand-paired calls. The identity-participation decision moves from eight inline conditions to one declaration per plan, and the update path’s validity lower-bound fence becomes a required argument instead of a spread convention. No public API, behavior, error type, or lock scope changes. -
#491
2009e6cThanks @pdlug! - Add the internal write-pipeline seam: a total, disjoint classification of everyGraphBackendmember, a typed write plan, per-kind write fences with total applier maps, the fused write session, and the executor that is the single sanctioned caller of the write transaction. An ESLint rule now bans direct backend mutation calls outside the step and sidecar modules that own them, with a declared exemption list a ratchet holds equal to the tree. No public API, behavior, statement order, or lock scope changes. -
#491
2009e6cThanks @pdlug! - Route the set-based node update through the write pipeline:updateWhere’s transaction body is nowapplyNodeSetUpdate, a node write step, reached throughsession.reviseNodeSetunder a write plan. The uniqueness drop it performs moves to the uniqueness sidecar module, and the fence the set UPDATE has no field to carry is now refused by name instead of being absent from the call. With this, no module outside the declared step and sidecar modules calls a backend mutation member for a node write. No public API, behavior, error type, or lock scope changes.
0.49.0
Section titled “0.49.0”Minor Changes
Section titled “Minor Changes”-
#488
0db8f9cThanks @pdlug! - AllowimportGraphandimportGraphStreamto stage interchange data directly into an opaque ingestion branch while preserving its deferred-uniqueness boundary. -
#486
b82d436Thanks @pdlug! - Let constraint-aware ingestion branches stage Operational Identity same and different assertions through a conditional assertion-only facade, so duplicate unique aliases and their identity evidence can reach merge planning together.
Patch Changes
Section titled “Patch Changes”-
#485
184fc96Thanks @pdlug! - Stop publishing a private workspace ESLint config in package metadata, preventing lockfile-refreshing pnpm installs from resolving an unpublished package. -
#489
f4e31d9Thanks @pdlug! - Translate missing fulltext storage failures from Cloudflare Durable Objects SQLite intoContributionUnavailableErrorwhile preserving the underlying database error and transactional rollback.
0.48.0
Section titled “0.48.0”Minor Changes
Section titled “Minor Changes”-
#476
a58ba03Thanks @pdlug! - Store no validity lower bound for a write that would otherwise be born already ended. A write that stamps avalid_fromthe caller did not state now stores the write instant only when doing so leaves a window some coordinate can read: if a statedvalidTofalls at or before that instant, the row is stored with no lower bound at all — “ended at T, start unknown” — and reads back at everyasOfbefore its end instead of at none (#407). The decision lives in the SQL builders, so it holds for everyGraphBackendcaller, including interchange import and trusted import, not only for the store paths.Three consequences worth naming:
meta.validFromisundefinedfor such a row, where it used to be an instant no query could match.- A resurrecting
upsertById/bulkUpsertByIdthat names a lone historicalvalidToon a tombstoned node no longer refuses: it reaches the same stored shape acreateon the same id reaches. One stated window, one outcome, whichever entry point resets the window. - A
validToin the future is unchanged — it still stamps the write instant, so a scheduled-end row stays invisible before it existed.
Rows already stored with an inverted window are not rewritten; they stay invisible at every coordinate until repaired.
Custom
GraphBackendimplementations should route node/edge insert stamping and node resurrection stamping throughresolveStampedValidityLowerBound, now exported from@nicia-ai/typegraph/backend, so adapter-specific builders cannot drift from the shared validity-window contract. Edge resurrection retains its stored lower bound and therefore does not stamp one. -
#477
09318d6Thanks @pdlug! - Recognize every spelling of a one-connection Postgres cap that its driver actually honors, so interchange refuses the pairs that would otherwise hang. Serialized-connection detection previously required a numericmax: 1, which missed three configurations that really do run every statement on one connection:new Pool({ max: "1" })and the legacynew Pool({ poolSize: "1" })— the shapemax: process.env.PG_MAXproduces. pg-pool never coerces the value, so the cap stays a string, and its own_clients.length >= options.maxcheck then coerces it: the pool really is capped at one.postgres(url + "?max=1")— postgres-js resolvesmaxfrom the URL and does not coerce it either.PGMAX=1with postgres-js — the same cap through the environment.
On those three backends, a streaming export/import pair now throws
INTERCHANGE_SHARED_SERIALIZED_BACKEND_SNAPSHOTwhere it previously hung, and two concurrent streaming imports now throwINTERCHANGE_SERIALIZED_IMPORT_IN_PROGRESSwhere they previously interleaved and succeeded slowly — the lease is exclusive across all four pairings, so this one conservative refusal is inherited verbatim from the existing numericmax: 1behavior.store.withWorkingCopyand branch cloning also switch from a streamed clone to a fully materialized in-memory export on those backends, a memory-profile change on large graphs.Deliberately still unmarked: a postgres-js client given a non-numeric string cap other than one (
?max=5), which opens exactly one connection today only because postgres-js does not coerce it — marking that would be marking on an upstream bug, and would refuse legitimate concurrent work the day the driver fixes it. Apgpool givenmax: "5"genuinely opens five connections and is likewise unmarked. Existing correctly-detected backends see no change: same marks, same refusal codes, same messages.Shipping in the same release, so nobody surprised by a new refusal is stuck:
createSqliteBackendandcreatePostgresBackendgain an optionalserializedResourcedeclaration.{ mode: "shared", resource: client }marks a connection TypeGraph cannot detect — the?max=5shape above, BunSQL,expo-sqlite,op-sqlite,sqlite-proxy,pg-proxy— and two backends naming the same object are one serialized resource.{ mode: "independent" }escapes a detection that is wrong for your topology. A"shared"declaration naming a different object than the one detected is refused with aConfigurationErrorcarryingdetails.reason: "serialized-resource-conflict"and a constructor-name description of each side (details.declaredKind/details.detectedKind, never the handles themselves —detailsis whattoLogString()serializes, and a driver handle there would log the credentials that driver stores) rather than silently preferred."independent"lifts the shared-resource refusal between two distinct backends — one SQLite backend exporting into itself still reportsINTERCHANGE_SAME_SQLITE_BACKEND_SNAPSHOT, which is a fact about one handle rather than a claim about connection topology. That surviving refusal is SQLite-only, so on PostgreSQL a backend declared independent may export into itself. -
#479
e285a71Thanks @pdlug! - Add constraint-aware ingestion branches that defer node uniqueness during staging and validate the resolved merge write set atomically. -
#476
a58ba03Thanks @pdlug! - AddrepairInvertedValidityWindows, the explicit operator action that makes rows an older version stored with a backwards window (valid_from > valid_to) observable again. Such a row is readable at no coordinate at all; upgrading deliberately rewrites nothing, so repairing is a decision an operator takes rather than a side effect of a deploy.mode: "report"counts and writes nothing — it reads throughexecute, a required backend member, so detection works on every backend including a history-capturing one and one with no statement-execution support.mode: "apply"normalizes the rows it counted to no lower bound (“ended at T, start unknown”), the shape today’s write paths store, and is idempotent and convergent.relationsis required and takes"live"or"live-and-recorded";"live-and-recorded"is recommended, because repairing only the live axis leaves the recorded twin inverted and re-materializes the invisible row at anyasOfRecordedcoordinate.The repair mints no revision, bumps no
versionand does not moveupdated_at: it normalizes storage for rows that were never observable, so it is not a logical write. Run it with writers stopped, and re-baseline outstanding merge branches afterwards —valid_fromis part of thebase@Vcontent fingerprint.applyrefuses rather than guessing on the states it cannot honor: a backend without statement execution, a recorded-capture backend, and (on SQLite, where bounds compare as text) a relation holding non-canonical bounds it cannot classify.
0.47.0
Section titled “0.47.0”Minor Changes
Section titled “Minor Changes”-
#475
4abc7baThanks @pdlug! - Add an opt-inidleTimeoutMssafety bound toexportGraphStream. The timeout measures how long a delivered chunk remains unacknowledged, then rolls back the snapshot transaction, releases the serialized stream lease, and reports the new typedExportStreamIdleTimeoutError. Time spent waiting for the backend does not count as consumer idleness, and existingAbortSignaland cooperativebreak/returncancellation behavior is unchanged. -
#468
c53c006Thanks @pdlug! - Add public snapshot and incremental merge planning APIs that return stable, JSON-serializableMergePlanArtifactvalues. Plans bind the reviewed write set to the target graph, active schema, durable revision origin and revision, carry a content digest, and can be applied exactly once withapplyMergePlan. Applying a plan validates the artifact and checks its fence atomically without re-running candidate generation, scoring, embeddings, canonical selection, or conflict callbacks. ExistingmergeandmergeIncrementalentry points retain their one-call compatibility behavior while sharing the same resolution, validation, and mechanical write owners.Explain entity resolution with deterministic decisive edges, complete built-in candidate-source attribution, and scored strategy/score/threshold evidence while keeping definitional matches distinct from similarity scores. Add opt-in, deterministically bounded accepted/rejected candidate diagnostics. Default evidence excludes raw compared values.
Custom similarity scorers that return
NaNor infinity now fail withMatchEvidenceError; non-finite values cannot be represented faithfully in a serialized evidence artifact. Candidate-source failures now use the more specificCandidateSourceError; legacydetails.sourceremains available alongsidedetails.sourceIdfor base-source configuration failures. -
#473
44d1486Thanks @pdlug! - Allow nodes returned by runtime string-keyed collections to participate directly in Operational Identity reads, assertions, bulk operations, and pair retractions. Identity result types now honestly include runtime-evolved members alongside compile-time graph references. -
#475
4abc7baThanks @pdlug! - Add explicit half-open validity windows to scalar and bulk Operational Identity assertions, with bounded temporal contradiction checks, endpoint coverage validation, archival interchange support, node-window integrity guards, scalable branch-merge validation, and report-visible window reconciliation. -
#469
8e50bdbThanks @pdlug! - AddclearValidTo: trueacross node and edge update/upsert APIs so applications can reopen an ended valid-time window without changing entity identity. Built-in SQLite and PostgreSQL backends apply the clear, unchanged replays coalesce,oneActiverelationships are rechecked when reopening, unsupported custom backends refuse explicitly, and graph merge carries branch-authored reopenings while rejecting delete-and-resurrect window artifacts. -
#475
4abc7baThanks @pdlug! - ReturnMergeConstraintConflictErrorwhen a resolved graph merge would violate a deterministic store constraint, preserving the typed store error as its cause and exposing actionable constraint details.
Patch Changes
Section titled “Patch Changes”-
#470
0b0022cThanks @pdlug! - Coalesce unchanged endpoint edge get-or-create updates whencoalesceUnchangedUpsertsis enabled. A coalesced replay now returns action"found";"updated"means an update actually ran.The coalescing check needs the endpoint match-key convergence fence. On a backend without top-level transactions, such as Cloudflare D1 or
neon-http, an otherwise unchanged endpoint replay now refuses withCONSTRAINT_WRITE_FENCE_UNSUPPORTEDinstead of running unfenced. The bulk endpoint form and the create leg already required this fence.This option does not coalesce node
getOrCreateByConstraintupdates; useupsertByIdfor replay projectors that need unchanged node writes to avoid history churn. -
#475
4abc7baThanks @pdlug! - Canonicalize node and edge metadata timestamps returned by compiled queries. All supported database drivers now expose the same fixed-width UTC ISO 8601 rendering through compiled-query projections and store collection reads. -
#475
4abc7baThanks @pdlug! - Serialize PostgreSQLpg_trgmextension installation across concurrent trigram index materializers. -
#475
4abc7baThanks @pdlug! - Preserve PostgreSQL vector index build failures throughout serial-fallback preparation and durableparallel_workerscleanup, report the exact manual repair when cleanup fails, and reset built-in pgvector tables before every materialization attempt so recovery survives backend recreation without mutating custom strategy storage.
0.46.2
Section titled “0.46.2”Patch Changes
Section titled “Patch Changes”-
#459
0e2afe2Thanks @pdlug! - ExtendonImmutableLowerBound: "preserve"to endpoint-matched edge writes.getOrCreateByEndpointsaccepts the policy in its options, andbulkGetOrCreateByEndpointsaccepts it per item alongsidevalidFromandvalidTo. The policy applies a statedvalidFromon create or resurrection, while a liveifExists: "update"preserves the stored lower bound and still applies properties andvalidTo. Strict refusal remains the default. -
#462
44f60cfThanks @pdlug! - Surface lost fulltext contribution storage on gated operations as a typedContributionUnavailableErrorwithstate: "physical-storage-missing"and rebuild guidance. Healthy operations retain the cached marker fast path; the error path translates only a missing-relation failure whose same driver error names the declared fulltext table.
0.46.1
Section titled “0.46.1”Patch Changes
Section titled “Patch Changes”-
#456
a091902Thanks @pdlug! - Add an explicit event-materializer policy for node upserts. PassingonImmutableLowerBound: "preserve"appliesvalidFromwhen the upsert creates or resurrects a row, but preserves a live row’s stored lower bound while still applying props andvalidTo. The strictIMMUTABLE_VALIDITY_LOWER_BOUNDrefusal remains the default. The policy is available onupsertById,upsertByIdFromRecord, and eachbulkUpsertByIditem, including unchanged coalescing replays.Widen the optional
better-sqlite3peer range through 13.x and exercise 13.0.3 in this repository. Correct the event-log projector guidance to update existing endpoint-matched edges, document historical replay window requirements, and clarify thatMergeReport.validityEndsonly reports inherited-row claims.
0.46.0
Section titled “0.46.0”Minor Changes
Section titled “Minor Changes”-
#427
facef56Thanks @pdlug! - AddsignaltoexportGraphStream, and keep a streaming import’s statistics refresh inside its connection lease.An export stream holds one
repeatable_read/read_onlytransaction for its whole life, and on a single-connection backend it holds that connection’s exclusive interchange-stream lease with it. Every cooperative exit already settled both, because each runs the generator’sfinally:breakorthrowout of afor await, and an explicititerator.return(). A consumer that pullsnext()and then simply DROPS the iterator — thePromise.race([iterator.next(), timeout])pattern — has no cooperative exit, because async-generatorfinallyblocks do not run on garbage collection. That leaked the snapshot transaction for the life of the process, and with it the lease, so every later export and every later import on that connection was refused for a stream nobody was reading.ExportOptionsSchemanow acceptssignal?: AbortSignal, so bothexportGraphandexportGraphStreamtake it, on both capability arms — a non-transactional export holds no snapshot and no lease, but it still owes its consumer an answer rather than a silent stall, and the same cancellation path gives it one. On a transactional backend, aborting rolls the snapshot transaction back and releases the lease whether or not anyone is waiting onnext(); the pull that is in flight when the abort lands — and a pull from a consumer that walked away and came back — rejects with the newExportStreamCancelledError(code: "INTERCHANGE_EXPORT_STREAM_ABORTED"), carrying the signal’s ownreasonascause. A signal that is already aborted refuses the export before any transaction is opened or any lease claimed. The listener is subscribed before anything is claimed or opened andsignal.abortedis re-checked immediately after subscribing, so an abort at any instant is either seen by that re-check or delivered to the listener — including one raised synchronously by a driver insidebackend.transaction(...), which anAbortSignalnever replays to a listener that arrives later. Everything else is unchanged: an export without a signal behaves exactly as before, and a cooperative exit still reports a clean end rather than a cancellation.There is deliberately no garbage-collection fallback. A
FinalizationRegistryon the iterable cannot work here — not merely unreliably, but never: the producer is interruptible only where it is parked waiting for the consumer, so any cleanup state able to settle an abandoned stream must reach the stream’s internal channel, and a registry holds its held value strongly, so holding anything that reaches that channel keeps the abandoned stream permanently reachable and the entry can never fire. The signal is the mechanism, and it is a contract rather than a hint.Separately,
importGraphStreamnow holds its target connection’s stream lease across the trailing planner-statistics refresh instead of releasing it when the chunk loop ends. ThatANALYZEis a write like the chunks were, and running it outside the lease left it to be stranded by an export snapshot opening in that window — swallowed as a warning, because the refresh is best-effort.importGraphnever had the hole (withImportStreamLeasespans its whole call), so this also removes a divergence between the two import surfaces. The lease is still released on every exit, including the error paths.Also fixes a silent cross-kind edge overwrite in import. Edge ids are unique per graph but the import’s existence probe (
getEdge/getEdges) is keyed on(graph_id, id)with no kind comparison, so a document edge of kind A whose id was already held by a kind-B row matched that row:onConflict: "update"wrote A’s properties onto the kind-B row with nothing inresult.errors, andonConflict: "skip"counted the document’s edge as already present when no edge of its kind existed. Both are now reported as a per-rowImportErrorprefixedINTERCHANGE_EDGE_KIND_CONFLICT, naming the stored kind and the stated one, with the stored row left untouched — the check runs before the conflict strategy, so all three strategies answer alike.backend.updateEdgeis additionally called withkind, whichUpdateEdgeParamsdocuments as MUST-apply, so the predicate lives in the UPDATE’s ownWHEREand the check cannot be raced by a concurrent hard-delete-and-recreate; a write that consequently matches no row is reported as the same per-row error rather than aborting the import. Nodes were never affected — their probe is kind-scoped.The export’s snapshot guarantee is now stated as the capability-scoped fact it is, in the API docs, the option docs, the error class, and the abort message: a backend reporting
capabilities.transactionsreads the whole export inside one repeatable-read transaction, while one without (SQLitetransactionMode: "none", session-less HTTP Postgres drivers) paginates statement by statement and can show a mid-stream write in later pages.ExportStreamCancelledError’s message now says which of the two it is describing, so a cancelled non-transactional export no longer claims to have rolled back a snapshot it never opened. -
#417
9d3014cThanks @pdlug! - graph-merge: judge the edge fold’s property union against base, and report a target-precedence window discardTwo adjacent gaps in the edge repoint/window path. One is a bug fix, the other adds an optional field to a report type, so this ships at the higher
minorbump and covers both.The repoint fold’s property union had no base to compare against, unlike the node path’s three-way merge, so a staged copy of an INHERITED edge contributed its whole fork property bag as first-class
(branch, value)claims — including the values it never touched. Under any rank-basedonPropertyConflictan untouched base value could therefore outvote a value a branch actually authored, decided by whichever branch label happened to ride on the untouched copy. The window-only carrier made it observable: an inherited row whose only change is its end-of-validity is staged solely to give that ending somewhere to ride, its properties ARE the base’s, and its branch is merely whichever sorted first in staging. The union now filters every contributor to the properties it CHANGED from its own base — a branch-created edge has no base, so everything it carries stays a full claim — which means a carrier contributes no claim and raises no conflict at any rank. Genuine disagreements are unaffected: two members that changed one property differently still conflict, over their real values alone.Filtering claims does not erase content: the folded row commits the same property set as before, and a key only a non-survivor carries keeps the value held by the member with the minimum edge ID — the row, never the branch label riding on it, since for these keys no branch claimed anything and an arbitrary label deciding the committed value is the very thing being fixed.
MergeReport.validityEndsnow also reports the window claims that target precedence discards. When the incremental target had already moved an inherited row’s end, the reconciler took the row out of the resolution and the branch claims vanished from the report entirely — less visible than a claim that merely lost the least-claim rule, which stays named inclaimedBy. Such a row now gets a resolution naming the target’s own committed instant, its discarded claimants, and the new optionalValidityEndResolution.precedencefield set to the exportedVALIDITY_END_TARGET_PRECEDENCE. The field is absent on every entry the merge itself decided, so existing consumers read what they always read; no write is staged and no provenance credit is minted for such a row, and a row no branch claimed still produces no entry at all. -
#364
fb29816Thanks @pdlug! - Add optionaltopKtopageRank()andpersonalizedPageRank(), and optionalminComponentSizetoweaklyConnectedComponents(). Both bound only result extraction: the limit and the inclusive component-size filter are applied in extraction SQL after the existing deterministic ordering, so bounded rows never reach the driver. Default results and ordering are unchanged, and the graph computation itself still runs over the whole visible induced subgraph. -
#415
b68e643Thanks @pdlug! - Refuse non-canonical validity-window timestamps in trusted import.trustedImportGraph/trustedImportGraphStreamaccept a pre-typed stream and never re-parse it, so avalidFrom/validTothat TypeScript types asstringbut is not canonical fixed-width UTC ISO 8601 used to flow straight to SQL. Every temporal filter compares those values AS TEXT against anasOfcoordinate, so a stored"2021-01-01","...T00:00:00Z","...:00.1Z"or"...+01:00"mis-sorts and silently includes or excludes the wrong rows — and it mis-decided the negative-width window check that the same path performs on the way in.Both window fields of every streamed node and edge are now format-checked with the same
isCanonicalIsoDatedecision the untrusted import schema and the store’s own writes make. A violation refuses the WHOLE stream with aTrustedImportErrorcarrying the existing reasoninvalid_stream, naming the offending field, row and value; the session’s transaction rolls back, so chunks already streamed are not left behind. This is a behavior change: a stream that previously imported and stored an unsortable timestamp now fails loudly. Convert such values withnew Date(value).toISOString(). The check is format-only — trusted import still skips property, reference and conflict validation — and it leaves an absent field and an explicitlynull(confirmed open-left)validFromuntouched.Also documents a pre-existing bulk-API limitation, with no behavior change:
bulkUpsertByIdgroups every create ahead of every update, so one batch cannot hand a constrained value from one row to another (releasing auniquevalue or aoneActiveedge slot and claiming it in the same batch throwsUniquenessError/CardinalityError, where the equivalent sequential upserts succeed). The workaround is two batches, or sequential upserts. -
#374
fadf932Thanks @pdlug! - graph-merge: stage cascade retractions with their cause instead of inferring intentA node soft-delete ends every open identity assertion touching the node, so a branch that deletes a node stages retractions it never asked for. The merge previously separated those cascade endings from a branch’s own retraction with a conservative branch-level heuristic, which deliberately over-dropped the same-branch case: a branch that retracted an assertion and LATER deleted one of its endpoints looked exactly like a pure cascade, so its retraction was dropped whenever the deletion was overruled — silently keeping truth the branch had explicitly ended.
The soft-delete cascade now ends assertions at the deleted node’s own
deleted_at, which makes the cause derivable: the state-diff compares each retracted assertion’s end instant to the deletion instants of its endpoints and stages the retraction as either a cascade naming the deleted node or the branch’s own act. The merge planner drops a retraction only when EVERY branch staged it as the cascade of a deletion that delete/modify resolution then overruled, so an explicit retraction survives even when it comes from the deleting branch. Two cases stay conservative because nothing distinguishes them at the stored resolution — a hard delete (which removes the assertion rows) and a retraction issued in the same millisecond as the delete that followed it. -
#387
71361d7Thanks @pdlug! - Complete the contribution health lifecycle with a read-only readiness probe and an explicit destructive rebuild, so the three maintenance operations form one escalation ladder:probeContributions()(writes nothing) →repairContributions()(non-destructive, already shipped) →rebuildContribution()(drops and recreates storage).store.probeContributions()answers “is search coherent with the graph right now” without mutating anything — safe on a read path, on a replica, and under a least-privilege role. It returns oneready/degradedentry per search projection plus the durablegraphRevisionthe assessment was taken at on a revision-tracked Store. It shares the detection logic ofverifyContributions()rather than reimplementing it, so a health check can never disagree with the gate the hot path actually consults. A projection with no declared contributions is omitted rather than reportedready, and a backend that provisions contributions but cannot probe its catalog refuses instead of answering — “assessed and healthy” and “never looked” never share a return value.store.rebuildContribution("fulltext")is the repair that was missing for astalecontribution, whose table exists at a shape the currentcreateDdlno longer produces: the ensure path’sCREATE ... IF NOT EXISTSno-ops against it, so re-stamping the marker would leave it blessing storage of the wrong shape. The rebuild drops the storage, recreates it, reconstructs the content from the node rows, and stamps the marker inside one transaction under the schema-write fence, so an interrupted rebuild rolls back rather than leaving storage attested but empty. It is reachable only by name — never fromrepairContributions(), which continues to report these findings asrequires-rebuild.Vector contributions are not rebuildable, and the call refuses with
ContributionRebuildUnsupportedErrorrather than dropping them: TypeGraph stores the vectors callers supply and never the inputs that produced them, so the embeddings exist only in the storage a rebuild would destroy.reembedVectorField(kind, fieldPath, { embed })remains the sanctioned destructive path, because it takes the callback that can regenerate them. The same typed error covers a fulltext strategy that declares nodropDdland a backend with no transactional schema fence; all three refuse before anything is dropped, and all three are declared ahead of time on the newbackend.capabilities.contributionscapability.Fixes the drift guard so the ladder can actually be climbed: when the guard refused a shape change it recorded the failed attempt at the new signature, overwriting the only evidence of the shape the table really had. The verdict then read as
missing-markerrather thanstale, sorepairContributions()reported it repaired — re-stamping the marker over the unchanged old-shape table — and the next boot skipped the guard entirely. The guard now preserves the recorded signature, so astalecontribution staysstaleacross restarts,repairContributions()keeps reportingrequires-rebuild, and the refusal persists untilrebuildContribution("fulltext")fixes the shape. Reach that call from acreateStore()/createVerifiedStore()Store: the managed factory’s boot step is what the guard refuses.Also adds optional
dropDdltoTableContribution— declared by both bundled fulltext strategies — which is what opts a strategy into the rebuild. -
#376
8c3a8e6Thanks @pdlug! - Add a database-level contradiction backstop for Operational Identity.A
differentassertion and asameassertion that would place both of its endpoints in one identity class are a contradiction, and until now only application code stood between such a write and a committed graph: the plan-time simulation and the identity applier’s validation both decide by reading state and comparing, so a bug in either commits the contradiction silently.Identity now also maintains a derived separation relation — one row per pair of identity classes a current
differentassertion holds apart, keyed by the two class keys under aCHECK (class_key_low < class_key_high)constraint. Every transaction that fuses two classes relabels the affected separation rows in the same statement batch, so fusing two separated classes relabels both sides of their shared row to one key and the database aborts the transaction. A write that reached the ledger through a path that skipped identity validation can no longer commit a contradictory graph; it fails with the new typedIdentitySeparationViolationError.The relation is derived and requires no application changes: it is maintained wherever the identity closure is (assert, retract, fold, delete, merge, import, rebuild),
rebuildIdentityClosure(store)recomputes it from the assertion ledger, and store-open identity validation checks it against that recomputation.Upgrading an existing identity-enabled database needs no manual step. A store opened with
createStoreWithSchema/createAdapterStoreWithSchemacreates the newtypegraph_identity_separationrelation through the same idempotent identity DDL path as the other identity relations and recomputes it from the ledger once, before anything reads it. A missing assertion ledger or closure relation is still refused as data loss.Custom backend authors: the resolved table-name types (
ResolvedSqlTableNames,SqliteTableNames,PostgresTableNames) and theensureIdentityTablesparameter each gained a requiredidentitySeparationentry, so an implementation that builds one of those objects needs the new name added. Code that only readsbackend.tableNames, or that passes a partial name override tocreateSqliteTables/createPostgresTables/createSqlSchema, is unaffected — an omitted name still resolves to the defaulttypegraph_identity_separation. -
#397
d2b935eThanks @pdlug! - Graph Merge now keeps the inherited edge when a repoint-induced collapse folds a committed row together with a branch-created one, instead of keeping the lexicographically-minimal edge id.Previously the survivor of such a collapse was whichever edge id sorted lowest. A collapse rewrites the row it keeps and ends none of the rows folded into it, so when a branch-created id sorted below the committed one, the merge wrote the branch’s row as a new edge and left the committed edge live beside it at its pre-merge properties — two live rows for one folded relationship, the edit staged for the committed row never written, and
merged.edgescounting one of them. Which of the two you got depended on an id sort, so it was not behavior a caller could depend on.The surviving edge id reported in
PropertyConflict.entityId, window resolutions and provenance is consequently the inherited row’s id whenever the collapse involved one. That is the id of the row that actually persists, and it no longer moves with branch-created id lexicographics. Collapses among branch-created edges alone are unchanged, as is the property/window reconciliation applied to the survivor. -
#427
facef56Thanks @pdlug! -store.rebuildContribution("fulltext")is now scoped to the graph it is called on. The fulltext projection is one physical table holding every graph’s rows keyed bygraph_id, while the rebuild runs under the per-graph schema fence — so the old unconditionalDROP TABLEdestroyed every other graph’s search index on the same database, with no concurrency required: a neighbouring graph’sfulltextmarker survived the drop (markers are keyed bygraph_id), soprobeContributions()andverifyContributions()went on reporting itreadywhile every search it served returned nothing. The rebuild now removes only the calling graph’s rows, through the sameDELETE ... WHERE graph_idstatementclear()uses — one exported builder both call — and escalates to dropping and recreating the shared table only when that table holds no other graph’s rows. That drop remains the one repair for storage provisioned at a shape the current DDL no longer produces, and the lock scope now matches the decision it authorizes. Two locks, protecting different resources: a constant-keyed advisory lock (typegraph:contribution-ddl) serializes the contribution’s DDL across graphs — it survives the drop and exists even when the table does not, which is what a relation lock cannot do — and, on the path that may drop,LOCK TABLE ... IN ACCESS EXCLUSIVE MODEexcludes ordinary writers, which take no advisory lock at all and could otherwise commit a row between the probe and theDROP TABLEthat the probe had already decided was safe. The verdict is re-established under that lock before any drop; the cheap unlocked probe ahead of it exists only to keep the graph-scoped path off the relation lock, and can only err toward keeping the table. Both are no-ops on SQLite, whoseBEGIN IMMEDIATEfence already holds the database’s single writer slot from probe through commit.When the recorded shape is
stale— the state only a recreate repairs — and the storage that would have to be recreated holds another graph’s rows, the rebuild refuses withContributionRebuildUnsupportedErrorand the new reasonshared-storage-in-useinstead of either destroying content it cannot reconstruct or re-stamping this graph’s marker over a physical shape nothing verified. The refusal names the other graph ids and the sanctioned maintenance-window sequence. Vector contributions are unaffected: their storage is per-(graph, kind, field), so no other graph’s data is ever in reach. -
#427
facef56Thanks @pdlug! - Harden the Operational Identity release and adjacent write paths found during its adversarial review. Identity interchange now exports one repeatable-read snapshot, uses target-bound keyset pagination pinned to code-point order via the dialect adapter’sbinaryTextseam so abase@Vcontent token minted on 0.45 still matches its recomputation on PostgreSQL, cancels cleanly, and refuses streams that would deadlock a serialized connection — a PGlite connection, a barepg/neonClient(including a checked-outPoolClient), aPoolexplicitly configured withmax: 1, a postgres-js client built with{ max: 1 }, a better-sqlite3 handle, abun:sqlitedatabase, a sql.js database, a local (file:/:memory:) libSQL client, or Cloudflare Durable Object storage, whose transaction frame is ambient on the storage object. The refusal is one EXCLUSIVE long-lived-stream lease per serialized resource, not a one-time observation and not a cross-kind-only exclusion: at most one interchange stream of any kind holds a given connection, so all four pairings are refused rather than only the two that mix kinds — an import behind an export snapshot (including through a user-wrapped stream that no longer identifies its source backend), an export snapshot behind a streaming import, and now export-behind-export and import-behind-import too, which previously reached the driver as a nestedBEGINafter chunks had already committed. Whichever long-lived stream starts second gets a typedConfigurationErrorinstead of both hanging:details.codenames the condition that holds the connection (INTERCHANGE_SHARED_SERIALIZED_BACKEND_SNAPSHOTbehind an export snapshot,INTERCHANGE_SAME_SQLITE_BACKEND_SNAPSHOTwhen the object-identity detector is what answered, or the newINTERCHANGE_SERIALIZED_IMPORT_IN_PROGRESSbehind another import), while the newdetails.requestedanddetails.heldByname which pairing was actually refused, so a same-kind refusal is never reported as something it is not."import-stream"is the kind of EVERY long-lived import, not only the chunk-streaming one:importGraphtakes the lease for the whole call andtrustedImportGraphStream/trustedImportGraphhold it for the whole trusted session, so both APIs can now throw these serialized-connectionConfigurationErrorcodes — a new error TYPE on a trusted-import surface that previously threw onlyTrustedImportError. Every exit releases the lease, including a mid-stream producer failure and a synchronous throw out ofbackend.transaction(...)(a closed handle, a refused pool checkout), which would otherwise strand the connection for the life of the process. Relatedly, SQLite’s manually framed transactions no longer let a failingROLLBACKmask the failure that caused it: SQLite auto-rolls-back onSQLITE_FULL/SQLITE_IOERR/SQLITE_NOMEM, so the unwindingROLLBACKcan itself fail with “cannot rollback - no transaction is active” — the caller now receives the ORIGINAL error and the rollback failure is warned instead of thrown. Graph merge uses injective composite keys, preflights provenance sidecar collisions, and refuses merge options it cannot honor instead of ignoring them.Edge identity checks now include kind and endpoint kind on every create, delete, and get-or-create path, including tombstoned rows, and that check is carried by the write statement itself rather than re-derived beside it:
UpdateEdgeParams,DeleteEdgeParams, andHardDeleteEdgeParamseach gain an optionalkind, and a backend that receives it MUST scope the statement to that kind. Both bundled Drizzle backends satisfy that contract through one shared predicate, but a hand-writtenGraphBackendhas to honor it or it will silently widen a write it was told to narrow. Because a kind-scoped statement that also requiresdeleted_at IS NULLis its own recheck, the redundant in-transaction re-read that used to precede each edge delete and hard delete is gone — the statement either matches the edge the caller named or affects nothing. NodebulkDeleteremains one atomic, hookless bulk operation exactly as in 0.45. EdgebulkDeletechanges behavior in 0.46: 0.45.x looped single deletes and fired per-itemonOperationStart/onOperationEndhooks for each one, and 0.46 makes it one atomic single-transaction batch that emits NO hook events at all — neither per-item nor bulk, sinceonBulkOperationStart/onBulkOperationEndfire only for nodeupdateWhereand no bulk-hook coverage for deletes exists yet — so a consumer that relied on those per-item events for audit or metrics must either keep deleting individually (singledeletestill fires per-item hooks) or capture the deletions another way, such as from the ids it passes in and the rows it reads back; an id in the batch that belongs to another edge kind is refused withValidationErrorcarryingEDGE_IDENTITY_MISMATCH_CODE, rolling back every delete already applied earlier in the same batch.Constrained writes no longer take their decision from a read the write cannot vouch for. Every write whose correctness rests on a check-then-write — edge cardinality
one,unique, andoneActive(including the create and resurrect legs ofgetOrCreateByEndpoints, single and bulk), node-kind disjointness on create, and akindWithSubClassesuniqueness constraint that actually expands to more than one kind (a scope covering a single kind probes exactly the row the uniques table’s own primary key then reserves, so that key IS its fence) — now runs its probe and its write under the same per-graph mutual exclusion, whether or not the store enableshistoryorrevisionTracking. That exclusion previously arrived only as a SIDE EFFECT of recorded capture’s advisory lock, so the DEFAULT PostgreSQL store — no history, no revision tracking — raced: two writers each probed a graph that satisfied the constraint and each committed, producing exactly the duplicate the constraint exists to prevent, with no error on either side. On PostgreSQL the fence is that same transaction-scoped advisory lock, now taken for the constraint’s sake rather than the clock’s; on SQLite it is theBEGIN IMMEDIATEwriter slot the backend already holds. Writes with nothing to check — an unconstrained create, any delete, a cardinality-manyedge — take no lock at all, so the cost tracks the constraints a graph actually declares rather than becoming a blanket serialization.A backend running WITHOUT transactions has no fence to take, and a constrained write there is now REFUSED rather than run unfenced. Both halves of the fence are transaction-scoped constructs — SQLite’s
BEGIN IMMEDIATE, PostgreSQL’spg_advisory_xact_lock, which outside a transaction is acquired and dropped inside its own implicit single-statement one and excludes nothing — so “can this backend fence” and “does this write run inside a transaction” are the same question, and the refusal is keyed on exactly that. It is aConfigurationErrorwithdetails.codeCONSTRAINT_WRITE_FENCE_UNSUPPORTEDanddetails.constraintnaming WHICH declared constraint needed the fence —edgeCardinality,edgeMatchKeyConvergence,nodeDisjointness, ornodeUniquenessScope— because “this backend cannot fence constrained writes” is unusable advice while “yourcardinality: 'one'edge cannot be enforced here” is actionable; thesuggestioncarries the per-class way forward. The blast radius is Cloudflare D1 (auto-detected astransactionMode: "none"),drizzle-orm/neon-http, and any SQLite backend explicitly built withtransactionMode: "none"— Durable Objects are NOT affected, sincedo-sqlitereportscapabilities.transactions: trueand fences normally. On those three, a declared-constraint write that previously raced silently now throws: acardinalityother thanmanycannot be created or resurrected, adisjointWithkind cannot be created, akindWithSubClassesunique that actually expands past one kind refuses on create AND update, andgetOrCreateByEndpointscan no longer take its CREATE leg (a call that FINDS an existing edge still returns it, and amanyresurrection is an id-keyed UPDATE re-deriving no verdict, so both keep working; the BULK form fences its whole batch and therefore refuses whatever the outcome would have been). Everything unconstrained is untouched — amanyedge created, updated and deleted, any node delete including one whose kind participates in a disjointness axiom, and ascope: "kind"unique whose uniques primary key IS its fence — so this is not a blanket loss of write access on those engines. Refused rather than degraded, per the accepted-or-refused rule: a constraint enforced only when nothing races is the exact defect the fence exists to close, and reporting it as enforced would make the invariant above false precisely where it matters.A store created with
coalesceUnchangedUpsertslikewise stopped letting an optimization change an answer. A single-nodeupsertByIddecided to skip its write from an autocommit read, so a writer committing between that read and the skip left the caller told its props were stored while the store in fact held the other writer’s — a DIFFERENT outcome from the same call with the flag off, where the update’s own in-transaction re-read merges the caller’s props over whatever it finds. The skip is now taken only on evidence re-read inside a transaction after that first observation, and a losing verdict falls through to the ordinary write path; only an upsert that is about to coalesce pays the second read, so a store without the flag, or one whose props differ, keeps the single read and single write it always had.getOrCreateByEndpointsnow converges rather than retrying once and hoping. Its single-shot retry became a bounded loop of three attempts whose ordinary case is cheaper than before — under the fence a losing writer learns of the winner from its own in-transaction lookup instead of from aCardinalityError— and whose exhaustion is a typed refusal rather than a livelock or a stray constraint error leaking out of a lost race: a competitor that repeatedly creates and removes the same match key ends in aDatabaseOperationErrornaming that pattern and telling the caller to serialize or retry. The bulk edgegetOrCreateByEndpointsand bulk nodegetOrCreatepaths, which had no retry at all and surfaced a concurrent winner as a raw constraint violation, gained one.efSearchis now refused everywhere it cannot be applied, not only on PostgreSQL. The guarantee that vector search never silently drops an accepted option held only for the PostgreSQL path: every SQLite backend acceptedefSearchand ignored it, on the vector path and the hybrid path alike (the hybrid path dropped it in a second place, while rebuilding the vector parameters), so a caller tuning recall got the default frontier and no indication. All of them now ask one owner, and an engine with no per-search ANN frontier refuses withUnsupportedBackendCapabilityError—details.capabilityvector.searchFrontierTuning,details.reasonnaming the limitation (sqlite-vec’svec0KNN takes onlyk, the page size; libSQL’s DiskANNvector_top_kfixessearch_lat index-creation time). PostgreSQL is unchanged, including its existing refusals for a non-HNSW slot and a driver that cannot scopeSET LOCAL, which now come from that same owner instead of from a second spelling of the same decision.Separately, sql.js backends could not execute a compiled query at all. The compiled-execution adapter recognized any client exposing
prepare()and then calledall()on the resulting statement, but sql.js’sStatementhas noall()— it is abind/step/getAsObject/freecursor — so the first prepared statement threw. Client detection is now shape-specific, sql.js is excluded from the compiled path, and it runs through Drizzle’s own session, which drives that cursor correctly;bun:sqlite, whose statement DOES exposeall(), keeps the compiled path.The merge-provenance sidecar now claims its graph id MARKER-FIRST instead of inferring ownership from circumstantial evidence: the durable
ProvenanceOwnermarker is the sidecar’s FIRST write of any kind, committed inside the schema fence (schemaWriteTransaction— the same per-graph fence every schema commit and schema-managed write already respects) and BEFORE the sidecar schema is registered, which is possible because the marker is a plain node row needing no per-graph DDL. A competing writer therefore either commits first and is seen, or waits until the claim has committed; a refused open leaves the occupant byte-identical, writing no schema row, no marker, and no provenance row. Because the marker precedes the schema, the resumable interrupted state is marker-WITHOUT-schema (or marker beside a pre-marker legacy schema), which resumes by registering or migrating the schema — while a graph carrying the exact current sidecar schema with NO marker is a state this module cannot produce and is refused UNCONDITIONALLY asunowned-exact-schema-graph, contents never consulted: empty and provenance-shaped occupants are refused exactly like any other, since contents an application could have written are not evidence of authorship. Freedom is judged by occupancy across EVERY per-graph row table the backend names through itstableNamesport — nodes and edges, but equally recorded-time history, the revision clock and origins, identity assertions with their recorded ledger, closure and separation, fulltext, and unique keys — so a graph id holding only, say, identity or fulltext rows is occupied and refused, and an unregistered schema is never taken as evidence of a free namespace. Only the exact validated live marker counts as ownership: a tombstoned, malformed, wrong-target, or non-canonicalProvenanceOwnerrow refuses with the reasoncorrupt-ownership-markerand is never overwritten or resurrected. Refusals report one of five typed reasons underGRAPH_MERGE_PROVENANCE_ID_COLLISION—application-graph,empty-legacy-sidecar,unupgradeable-legacy-sidecar,unowned-exact-schema-graph, orcorrupt-ownership-marker— each carrying remediation specific to the state actually found, and a backend that exposes no schema fence refuses an unclaimed sidecar withGRAPH_MERGE_PROVENANCE_CLAIM_UNFENCEDrather than claiming without atomicity (an already-owned sidecar needs no claim and still opens there). One writer class takes neither the per-graph advisory lock nor the active schema row — a schema-LESS rawcreateStorewriter, or a directbackend.insertNode/insertEdge— and at PostgreSQL’s READ COMMITTED its insert could commit between the claim’s fenced re-inspection and the claim’s commit, leaving the marker on a graph id an application had just made its own. That window is closed rather than accepted: on PostgreSQL the claim issuesLOCK TABLE <nodes>, <edges> IN SHARE ROW EXCLUSIVE MODEinside the fence and before the re-inspection, draining in-flight row writers and holding new ones off until the marker commits.SHARE ROW EXCLUSIVEand notSHAREbecause the mode must be SELF-exclusive: two concurrent claims on different sidecar ids hold different advisory locks, so underSHAREboth would acquire it and then both requestROW EXCLUSIVEfor their own marker INSERT — a lock-upgrade deadlock PostgreSQL resolves by aborting one. The cost is real and bounded: while a claim runs, every node and edge write on the whole DATABASE waits, for the duration of a few probes and one INSERT with no caller code inside — and the lock is taken only when a sidecar is created, upgraded from the pre-marker schema, or resumed after a crash, never on the common path where an already-owned sidecar opens with no fence at all. SQLite takes no such lock;BEGIN IMMEDIATEalready owns the single writer slot.persistProvenance: trueis now honored or refused, never dropped: the sidecar is opened and claimed PRE-COMMIT, so an occupied sidecar graph id or a backend that cannot fence the claim refuses the whole merge asInvalidMergeOptionsError(details.option"persistProvenance", with the originatingConfigurationErrorascauseand its code echoed asdetails.provenanceErrorCode) and leaves the target unmodified — where 0.45 would have committed the merge and reported the same configuration verdict as awarningsentry. Those verdicts are as true before the merge as after it, so reporting them post-commit left the caller with a committed graph and a stated option TypeGraph had silently ignored. The post-commit best-effort warning path survives only for what is genuinely transient — a row write failing against a sidecar this library already owns. One visible consequence of claiming early: after apersistProvenancemerge the sidecar (marker and schema) exists even if the merge itself later fails, holding an owned, empty sidecar and no target change.mergeIncremental’s refusal of a non-"flag"onBasePropertyConflictis now a typedInvalidMergeOptionsError(MERGE_ERROR_CODES.invalidOptions, categoryuser) instead of a plainMergeError, so it is catchable the same way every other refused merge option is.mergeIncrementaladditionally refuses a fork point that moved under it. Its plan is a set of diffs against onebase@V, and only the TARGET was ever allowed to advance while the merge ran — but nothing checked, so a write landing on the fork-point store mid-call left the commit applying diffs against an ancestor that no longer existed. The fork point is now frozen for the duration of the call: the version read before planning is carried into the commit and re-compared as the first act of the commit transaction, and a mismatch raisesBaseVersionMismatchError(GRAPH_MERGE_BASE_VERSION_MISMATCH) naming the expected and live fork-point bases rather than committing. Andbranch()no longer leaks the working copy’s backend when the post-transfer schema-anchor read fails — ownership of that engine transfers tobranch()on the strategy’s success path, and abranch()that reports failure aserr(...)hands the caller no handle to close.Operational Identity’s derived separation relation is never published in a state that under-reports separations. The relation is created INSIDE the transaction that fills it — the fenced path issues its DDL under
schemaWriteTransaction, the schema-commit path returns the DDL as data for the commit transaction to issue — so a commit refused by theIDENTITY_PROFILE_MIGRATION_PENDINGgate, a stale CAS, or a contradiction now creates nothing at all, where previously it stranded a readable, empty relation that the next open skipped because “present” was what suppressed the rebuild. What that cannot undo, a per-graph predicate heals: the fill decision is “does THIS graph hold livedifferentassertions and no separation rows”, not “does the table exist”, so a relation left empty by an older version or by another graph’s provisioning is rebuilt at the next open of the graph that owns the assertions. The predicate is exact in both directions — it shares the fill’s registry kind filter and additionally requires the assertion’s two endpoints to resolve to DIFFERENT identity classes, since a contradicted ledger projects to a degenerate pair the relation’s CHECK refuses, which is its own fault with its own error rather than an unfilled relation. Identity DDL is serialized database-wide by a constant-keyed advisory lock (typegraph:identity-ddl; a no-op on SQLite, whose writer slot already serializes the database), taken inside the per-graph schema fence and outside the per-graph identity locks, because the identity relations are shared by every graph while the fence is not. A backend that cannot publish that upgrade atomically — missingschemaWriteTransactionoridentityTableDdlon the fenced path, orexecuteSchemaDdlon the commit path — is refused with the newConfigurationErrorcodeIDENTITY_UPGRADE_REQUIRES_ATOMIC_DDLnaming the missing ports, but only when a fill is actually owed; both bundled Drizzle backends implement all three when transactions are enabled. Finally,isSeparatedno longer trusts an empty read: a graph with zero separation rows whose ledger holds a live, kind-filtereddifferentassertion across two distinct classes raisesIDENTITY_STORAGE_MISSINGwith the newdetails.reason"unfilled"rather than answering “not separated”. That is the state a Store handle opened while the relation did not exist would otherwise slide into the moment another graph’s upgrade created the shared relation mid-session; the remedy is to reopen the Store, which runs the fill, and the error says so. That proof is taken once per Store handle rather than once per read: it settles a property of the graph, not of the pair, and the assertion ledger has no index that answers it cheaply — proving it per read cost a 200-assertion same-only import 32% and eight concurrentassertSame56% on SQLite, on the workload class whose separation relation is legitimately empty forever. A handle opened while the relation was absent still refuses, because a handle that cannot read the relation never records a proof; what a kept proof no longer re-detects is a relation truncated out of band midway through one handle’s life, whichvalidateIdentity()reports and the CHECK constraint still refuses at the next fusing write.Three smaller guards join that one. The recorded revision clock can no longer move backward: its upsert advances the stored row only WHERE the stored revision is strictly less than the one being written, so a late allocation cannot rewind a clock other readers have already passed, and a caller that supplies an explicit stale
previousRevisionnow gets aConfigurationErrorstating that the write would have moved the graph’s revision clock backward — carrying the graph id and both revisions — instead of quietly winning. Operational Identity’s SQLite writes now name the one state they cannot recover from: an identity mutation inside a transaction the CALLER began and TypeGraph adopted (store.withTransaction(externalTx)/store.withRecordedTransaction(externalTx)) can find its read snapshot invalidated before it ever takes the writer slot if that transaction was openedBEGIN DEFERREDand another connection committed first, and SQLite cannot upgrade a stale snapshot in place. That surfaces as aConfigurationErrorwithdetails.codeIDENTITY_TRANSACTION_NOT_WRITE_FENCEDanddetails.sqliteCodeSQLITE_BUSY_SNAPSHOT, telling the caller to roll back and reopen withBEGIN IMMEDIATE; TypeGraph’s own transactions already open that way, so the state is unreachable without an adopted frame. And the three PostgreSQL error shapes a concurrentCREATE ... IF NOT EXISTSrace can take — SQLSTATE23505,42701, andXX000carrying “tuple concurrently updated” — are now classified by one shared predicate rather than by each call site’s own partial spelling of the set, so a race one site tolerated is no longer a hard failure at the next.Edge
matchOncomposite-key construction and its per-field match comparison, embedding/fulltext field extraction, and the uniqueness path — both the unique-key computation and thewherepredicate’s evaluation — now read a props bag by declared own key rather than plain property access, so a field named after anObject.prototypemember (toString,constructor,valueOf) can no longer resolve to the inherited prototype member instead of the field’s actual (absent) value: a unique constraint over an absent field namedtoStringkeyed on the inherited function, producing the empty key underbinarycollation and throwingTypeErrorundercaseInsensitive, where it must key as absent like every other missing value. The remaining NUL-joined cache and bucket keys in edge and node operations, and legacy provenance record ids, are now built with the same injective tuple encoding already used elsewhere, closing the last collision-prone key constructions.That own-key discipline now extends past reads of a props bag. A prototype-named field the schema DECLARES is projected and returned as its stored value instead of being short-circuited as prototype noise, so selecting a field called
toStringorvalueOfanswers with what was written rather than with the inherited function; the field tracker asks the schema introspector whether the name is declared before deciding, and an UNDECLARED prototype name still resolves exactly as it always did. Schema canonicalization builds its sorted form on a null-prototype bag, so a schema carrying a__proto__property is no longer canonicalized — and therefore hashed and diffed — identically to one where the property is absent; the output is byte-identical for every schema without such a key, so no existing schema’s hash moves. Graph merge’s property bags got the same treatment, which is what lets a fork-side DELETION of a__proto__property record as a deletion: the deletion marker is an assignment, and on an ordinary object literalObject.prototype’s setter swallowed it, silently reverting the delete to the base value. A graph-extension document that declares a PROPERTY named__proto__is refused outright withRESERVED_PROPERTY_NAME, because schema validation cannot carry it — at any depth, so a NESTED object field named__proto__is refused on the same grounds rather than only a top-level one.defineNode/defineEdgerefuse the identical declaration at definition time (ConfigurationError,details.conflicts) — at ANY nesting depth, walking nested object schemas and every wrapper (optional, nullable, default, arrays, records, unions, lazy) structurally through Zod’s publicdef, with a dotted path in the error — so the two authoring paths no longer disagree about the same unstorable field: it was a typed refusal on the document path and silent data loss on the typed one. It is reachable only through a computed key —z.object({ __proto__: … })written literally sets the shape object’s prototype instead of creating an entry, whilez.object({ ["__proto__"]: z.string() })yields a shape whoseObject.keysreally does contain it — and it is UNSTORABLE rather than merely reserved, because Zod drops the key from every parse result and reports success even when the field is required.The same misreading has a WRITE side, and it is now closed as a class rather than case by case.
bag[key] = valueon a{}literal does not create an entry whenkeyis__proto__: it invokesObject.prototype’s__proto__setter, which reparents the bag for an object value and does nothing at all for a primitive, so the value is dropped and every later own-key read agrees the writer never wrote it. Kind names (isValidKindNameadmits__proto__exactly as it admitstoString), schema property names, JSON-Schema keywords, query aliases andJSON.parsed document keys are all data, and all of them admit it.normalizeEdgesindefineGraphand every other data-keyed accumulator in the tree now build through one owner,createDataKeyedBag, so an EDGE kind named__proto__survivesdefineGraph, schema serialization, and a live store round trip instead of vanishing between the config and the registration. Because the class had already recurred twice from an incomplete enumeration, it is made self-enforcing: a ratchet test scanssrc/**for statement-position{}initializations and fails on any that is not allowlisted with a stated reason. Behavior note for callers: none of this is observable on returned values — every record TypeGraph hands back (serialized schema maps, aggregate rows, select contexts, migration counters, extension documents) accumulates on a null-prototype bag internally and is spread into an ordinary object at the public boundary, which preserves an own__proto__alias as data while restoringObject.prototype, sorow.toString()andrecord instanceof Objectbehave exactly as before. Relatedly, the field tracker and the selective projection now track a DECLARED field namedtoJSONas the stored data it is, instead of exempting the name unconditionally; the exemption survives, unchanged, for kinds that do NOT declare it, where it exists only to keep an incidentalJSON.stringifyof the tracking context from being recorded as a field access.Two remaining leaks of that internal null prototype are closed, and one of them was a behavior that depended on which query plan ran. A smart-selected alias object and its
metaare guarded PROXIES, and a proxy’s target is caller-observable —instanceof,Object.getPrototypeOfand every other internal method resolve against it, and nogettrap can disguise it — soctx.p instanceof Objectansweredfalseunder a selective projection andtrueunder the full mapper, for the same query. The boundary spread now happens inside the guard, so both mappers hand back objects rooted atObject.prototypewhile a projected field named__proto__survives as an own key; the tracking context handed to theselectcallback on the field-tracking pass got the same treatment, so the probe and the engine agree.TransactionReceipt’swrites.nodesandwrites.edgesare likewise ordinary objects now, matchingwrites.identity, which always was — a__proto__kind still reads back as an own key with its count. And the builder handed to an indexwhere:callback is no longer built on a null prototype either.defineNode/defineEdgenow REFUSE a schema containing az.lazy()whose getter cannot run yet, with aConfigurationErrornaming the kind and the dotted path. This is reachable from one shape: a mutually recursive pair declared AROUND the definition call, so the secondz.object()const is still in its temporal dead zone when the first one’s getter fires. Previously that branch was skipped, and skipping it was a fail-open — a definition is validated exactly once, so a__proto__nested under the unreadable subtree was accepted at definition time and then silently dropped by every parse, which is the precise outcome the unstorable-name refusal exists to prevent. Recursion itself is not refused: declaring both consts before the definition — which the error message asks for — resolves every getter, and the walk then reports the real conflict at its full nested path. Note that az.lazyproperty field is typedunknownby the query introspector regardless, so predicates over it degrade; recursive property schemas are not a supported shape, and this makes the one silently-wrong case loud.An import UPDATE now asserts every component its verdict read, closing the temporal half of the class rounds 6 and 7 closed for edge kind and endpoints.
UpdateNodeParamsandUpdateEdgeParamseach gain an optionalexpectedValidFrom, with the same MUST-apply contract asUpdateEdgeParams.kind— a backend that receives it has to put it in the statement’s ownWHERE, and the three states are distinct: omitted asserts nothing,nullassertsIS NULL(an open-left window), a string asserts equality. Both bundled Drizzle backends satisfy it through one shared NULL-safe predicate builder; a hand-writtenGraphBackendmust honor it or it will silently widen a write it was told to narrow. All four import update legs — node and edge, batched and per-row — now state the bound they validated the document’s window against, so a concurrent hard-delete-and-recreate between the probe and the write matches no row instead of ignoring avalidFromthe document stated or persisting avalidTobelow the new row’svalidFrom. They state it on exactly the terms the store paths do, because it is the same verdict object: a document naming neithervalidFromnorvalidTomade the verdict read no bound, so its properties update is fenced on identity and liveness alone and a concurrent recreate that only moved the bound no longer refuses it. A node write that consequently matches nothing is reported per row with the new message prefixINTERCHANGE_NODE_UPDATE_TARGET_CHANGED; the edge equivalent keeps the publishedINTERCHANGE_EDGE_KIND_CONFLICTprefix, with the validity bound added to its message text.ImportErrorstill carries nocodefield, so the prefix is the branchable token. Relatedly, a node update’s row write and its uniqueness transition now commit or fail as ONE unit: the new keys are claimed before the row write (the claim upsert reports the key’s final owner, so it IS the conflict gate, and a transaction holding the key cannot lose it to a peer), the row write follows, and the old keys are released only once it lands — with the claims compensated away if it does not. Import is the reason this has to hold on its own terms rather than on the transaction’s:onConflict: "update"catches a per-rowUniquenessError, records it, and commits everything else, so an ordering where the claim can fail AFTER the row changed reportedupdated: 0for a row whose props HAD changed, whose old reservation was released, and whose new reservation belonged to another node. The fulltext and embedding syncs still run after the row write, since a write that lands on nothing must not re-derive them.The same fence now covers the STORE update paths, which read the probed row’s
valid_fromfor exactly the same verdict.store.nodes.*.update/upsertByIdandstore.edges.*.update/upsertByIdcarryexpectedValidFrominto the statement’s ownWHERE— but only when the window verdict actually consulted the row’s bound, which is when the caller stated avalidFromto compare against it or a lonevalidToto invert against it. A plainupdate({ props })names no window, reads no bound, and is fenced by nothing extra; that conditionality is not an optimization but the same “only what it asserted” rule the edge identity components already followed, since predicating a write on a component the caller never claimed refuses writes that are legitimate. The decision has one owner, and it hands over the predicate rather than a flag:assertWritableValidityWindownow RETURNS theexpectedValidFromfence its verdict obliges the write to carry — empty when the verdict read no stored bound — so the answer comes from the branches the guard actually took and there is nothing left for a caller to re-derive. Interchange import had re-derived it, asserting the probedvalid_fromunconditionally and over-fencing exactly the props-only updates the store paths left alone; that second spelling is gone rather than corrected. When the assertion does catch a replaced row the update CONVERGES rather than failing: it re-reads, re-merges the caller’s partial props over the current props, and re-judges the window against the current bound, so a stated window that no longer fits is refused with the same typedValidationErrorit would have raised on the first attempt, and one that still fits is applied to the row that really exists. Convergence is bounded at one retry; a peer that keeps replacing the row ends in aDatabaseOperationErrornaming the contention instead of a livelock or a false “not found”. Two adjacent defects in the same paragraph of code are fixed with it:applyNodeResurrectreserved its uniqueness keys before the gatingdeleted_at IS NOT NULLupdate and kept them when the gate refused, so a resurrection that lost its race left reservations behind for a revival it never performed (it now runs through the same claim/gate/release transition asapplyNodeUpdate, which gives the reservations back when the gate matches nothing); and the bulkgetOrCreateByConstraintdecided whether to resurrect from the uniques row its batch probe captured, while the single-item path decided from the node row it was about to write — one decision with two owners, now read from the node row on both.Relatedly, the builder a uniqueness
whereclause names fields on now answers for every declared field rather than only the fields the props bag happens to carry — which is what its type has always promised (-?makes every schema field required on the builder, precisely so a partial constraint can ask whether an OPTIONAL field is present). Naming an absent field previously hit the builder object’s prototype instead: an everyday partial constraint over an absent optional field threwTypeError: Expected a defined valuefor every node written without it, and a field namedtoStringfoundObject.prototype.toStringand threwisNull is not a function. Such a field now evaluates as null, which is what a partial constraint means by absent — the same builder shape schema serialization has always captured awhereclause with.defineGraphalso refuses awhereclause it can already see is broken, at definition time rather than on the first write it distorts: a callback that returns something other than a predicate, or a predicate naming a field the kind’s schema does not declare, throws aConfigurationErrornaming the kind, the constraint, and — for the undeclared field — the fields the kind actually declares. A statically typed caller could express neither mistake, so this bites generated or untyped definitions, where the old behavior was a constraint that quietly matched every row or partitioned on a field that was absent forever. A third state joins those two: a constraint carrying awhereon a kind whose schema exposes no.shape— not an object schema, so there is no declared-field set to check the clause against — is REFUSED rather than left unvalidated, because skipping the check silently would disable the guard for exactly the untyped callers it was written for, who are also the only callers able to put a non-ZodObjectthere. Narrowly so: a plainunique: [{ fields }]on such a schema needs no shape to be meaningful and still works. And the same malformed clause is refused at EVALUATION too, not only at definition —checkWherePredicatethrows the equivalentConfigurationErrorfor a callback that returns a non-predicate, so the third reader of awhereclause now agrees with the other two (definition-time validation and persistence-time capture, all three reading the clause through one owner) instead of quietly treating a broken constraint as one that applies to every row. That matters for constraints built outsidedefineGraph, which never passed the definition-time gate. Because the check evaluates the clause, awherecallback now runs one extra time when the graph is defined, so it must be pure — which it already had to be, since the uniqueness path evaluates it per write. This validates node kinds whose schema exposes an object shape; edgeuniqueconstraints are not covered.This adds public API surface —
InvalidMergeOptionsError,ExportStreamCancelledError,EDGE_IDENTITY_MISMATCH_CODE,MERGE_ERROR_CODES.invalidOptions, theGraphBackend.identityTableDdlport with itsIdentityTableNamestype, theVectorSearchFrontierTuningtype,ExportOptionsSchema’ssignal?: AbortSignal(accepted by bothexportGraphandexportGraphStream), the optionalkindonUpdateEdgeParams/DeleteEdgeParams/HardDeleteEdgeParamsfrom the@nicia-ai/typegraph/backendentry point plus the four optional endpoint assertionsfromKind/fromId/toKind/toIdonUpdateEdgeParamsalone (one assertion that moves together or not at all, under the same MUST-apply contract askind), the"shared-storage-in-use"member ofContributionRebuildRefusal,ValidationErrorDetails.operationwidened with"delete"and"hardDelete", thedetails.requested/details.heldBypairing on every serialized-connection interchange refusal, and the new error codes:INTERCHANGE_EXPORT_STREAM_ABORTEDonExportStreamCancelledError, thevector.searchFrontierTuningvalue ofUnsupportedBackendCapabilityError’sdetails.capability, and the interchange, identity, and provenanceConfigurationErrorcodes (INTERCHANGE_SERIALIZED_IMPORT_IN_PROGRESS,INTERCHANGE_SHARED_SERIALIZED_BACKEND_SNAPSHOT,INTERCHANGE_SAME_SQLITE_BACKEND_SNAPSHOT,IDENTITY_UPGRADE_REQUIRES_ATOMIC_DDL,IDENTITY_TRANSACTION_NOT_WRITE_FENCED,IDENTITY_STORAGE_MISSING’s newdetails.reason"unfilled",GRAPH_MERGE_PROVENANCE_ID_COLLISIONwith its five refusal reasons,GRAPH_MERGE_PROVENANCE_CLAIM_UNFENCED, andCONSTRAINT_WRITE_FENCE_UNSUPPORTED, whosedetails.constraintcarries one ofedgeCardinality/edgeMatchKeyConvergence/nodeDisjointness/nodeUniquenessScope— the four members of the internalConstraintFenceReasonunion, which is not itself exported; branch on the string values).Three refusals join the surface without a stable
details.code, so match them by class plusdetailsrather than by code.assertApproximateMetricSupportedthrows aConfigurationErrorwhensimilarTo(..., { approximate: true })is combined with ametricoverride that differs from the slot’s declared metric, carryingdetails{ nodeKind, fieldPath, requestedMetric, declaredMetric, indexType }.defineNode/defineEdgethrow aConfigurationErrorfor a schema property named__proto__, carryingdetails.conflictsand anodeType/edgeTypekey. And a per-row import failure whose message is prefixedINTERCHANGE_EDGE_KIND_CONFLICTappears inresult.errorswhen an interchange edge’s id belongs to another kind —ImportErrorhas nocodefield, so the message prefix is the branchable token, following the existingwindowErrorOfidiom used by the validity-window import errors.Two of those are BREAKING for callers who reach past the bundled implementations.
VectorCapabilities.searchFrontierTuningis REQUIRED, not optional: a hand-written vector strategy must now state whether its engine has a per-search ANN frontier knob —{ tunable: true, parameter, indexType, requiresTransactionScope }or{ tunable: false, reason }— rather than inheriting silence, which is the exact defect the field closes, and a strategy that omits it no longer compiles. And a hand-writtenGraphBackendmust apply the newkindon the three edge params when it is present, and the four endpoint assertions onUpdateEdgeParamsalongside it; a backend that accepts and ignores either turns a write the caller narrowed into an unscoped one, and nothing above it re-reads to catch that any more. Kind alone is not enough for the update: an edge’s endpoints are immutable for a given row but its id is not, so a concurrent hard-delete-and-recreate under the SAME kind with DIFFERENT endpoints satisfies a kind-only predicate, and an upsert that resolved the id BY endpoints would write to an edge pointing somewhere it never looked. The endpoint fields are stated only by a write that actually checked them — a plainupdateon a kind-scoped collection resolved the edge by id and kind and states none of them, because predicating on endpoints it never checked would refuse legitimate writes.tests/edge-write-self-verification.test.tsasserts the contract against the bundled backends.It also changes behavior callers can observe: edge
bulkDelete’s hooks;importGraph/trustedImportGraph/trustedImportGraphStreamnewly throwing the serialized-connectionConfigurationErrorcodes (a new error type on the trusted-import surface);persistProvenance’s new pre-commit refusal turning a merge that previously committed-and-warned into one that refuses without touching the target;mergeIncrementalrefusing a commit whose fork point moved mid-call, where it previously committed diffs against a vanished ancestor; a stale explicitpreviousRevisionnow refused instead of rewinding the recorded clock; a SQLite vector or hybrid search that suppliesefSearchnow throwingUnsupportedBackendCapabilityErrorwhere it previously searched at the default frontier and said nothing;defineGraphthrowing on a uniquewhereclause that names an undeclared field, returns a non-predicate, or sits on a kind whose schema is not an object schema, and evaluating every such callback once at definition time; a graph-extension property named__proto__refused withRESERVED_PROPERTY_NAMEat any nesting depth, and the same name in adefineNode/defineEdgeschema refused with aConfigurationError; a constrained write on D1,neon-http, or atransactionMode: "none"SQLite backend now refused withCONSTRAINT_WRITE_FENCE_UNSUPPORTEDwhere it previously committed unfenced;similarTowithapproximate: trueand a mismatchedmetricoverride now refused where it previously served the exact scan and dropped one of the two options silently (store.search.vector/hybridalready refused every mismatched override on their own broader rule, and the builder’s EXACT path stays deliberately wider — only the silent half is closed); an interchange edge whose id belongs to a row with a different kind OR different endpoints now reported as a per-rowINTERCHANGE_EDGE_KIND_CONFLICTerror naming the mismatched components, underonConflict: "update"(which previously overwrote the other row’s properties while silently keeping its endpoints) and"skip"(which previously counted it present and silently lost it) — and the import’supdateEdgestatements carry the full identity assertion in their ownWHERE, closing the concurrent-recreate window for endpoints exactly as it was closed for kind; andMergeIncrementalArgs.optionsnarrowed toOmit<MergeOptions, "target">— a compile error for code that passedoptions.targettomergeIncremental, and a runtimeInvalidMergeOptionsErrorfor untyped callers that still do, where the namedtargetargument previously won andoptions.targetwas silently ignored. So this release ships as aminor, not apatch. -
#426
eb4b9c1Thanks @pdlug! - Refuse avalidFromthat a live row’s update cannot store, instead of accepting it and writing without it.An in-place update never rewrites
valid_from; only a resurrection does. Stating a bound that named a different instant used to block coalescing, so the upsert wrote — bumping the version and capturing a history row — while the bound itself was dropped at the SQL builder and the row’s window never moved. It now raises aValidationErrorwhose issue carries the new exported codeIMMUTABLE_VALIDITY_LOWER_BOUND, naming both the stated instant and the one the row holds so the caller can restate it without a second read.This reaches every path that accepts
validFromagainst a live row:upsertByIdandbulkUpsertById(nodes and edges, including a repeated id in one batch, which is judged against the row the batch just queued),getOrCreateByEndpointsandbulkGetOrCreateByEndpointswithifExists: "update"— which previously dropped the option before it reached any guard — and interchange import’sonConflict: "update"legs, where it is recorded as a per-row error prefixed with the code rather than aborting the import.What stays legal: restating the bound a row already holds (nothing to apply, so nothing is ignored); a create or a resurrection, both of which store a stated bound and are the way to give a row a different one; zero-width windows; and
getOrCreateByEndpointsreturning an existing edge, which performs no write at all.Previously-accepted writes now refuse, so this is a MINOR bump — the same precedent as the window refusals in the two releases before it.
Note for temporal imports: replaying an
includeTemporal: trueexport over rows that were created separately now reports those rows instead of updating their props under a lower bound it ignored. OmitvalidFromfrom the update document, export withincludeTemporal: false, or import into a fresh graph. -
#383
bab0752Thanks @pdlug! - Merge an inherited row’s end-of-validity instead of discarding itupdate(id, {}, { validTo })on a branch is an ordinary write, but the merge silently dropped it: modification detection compared properties only, so a branch that ended an inherited node’s or edge’s validity merged as a no-op. There was no workaround preserving row identity and history — deleting the row was the only statement the merge honored, and it is a strictly stronger one.An end-of-validity is now treated as a sibling of deletion:
- one branch ends a row → that end is committed, including a later end that extends the window;
- several branches end it differently → no conflict; the earliest end wins (a fixed, commutative rule, so the merge stays order-independent);
mergeIncremental()’s target already ended it → the target’s end stands, the same committed-target precedence identity survivors already get;- one branch ends it and another deletes it → deleted, with no
DeleteModifyConflict— the stronger statement absorbs the weaker one; - a branch re-states the end the target holds → nothing is staged at all: no write, no version bump, no history row.
MergeReportgainsvalidityEnds, listing every row whose end the merge changed and the branches that claimed it — the arbitration is silent by design, so this is how a caller sees it happened. Window deltas the commit cannot apply to a live row (a forkvalidFromdivergence, or avalidTocleared back to open — both reachable only by soft-delete + resurrect inside a fork) are now reported indroppedwith reason"window-not-applicable"instead of being ignored.Behavior change. Merges where a branch ended an inherited row now write that end, so new version bumps, history rows, and recorded-time entries appear where a no-op used to be. There is no opt-out flag: a permanent knob for “does the merge lose data” is worse than this note. Nothing that previously succeeded now fails.
Also hardens
coalesceUnchangedUpserts: the requested and stored valid-time bounds are compared as instants rather than as raw text, so the decision cannot come to depend on a dialect’s timestamp rendering. A bound that is not a representable instant still counts as a change, so it reaches the write path that rejects it rather than being coalesced away. -
#426
eb4b9c1Thanks @pdlug! - Report a non-canonical validity bound whether or notcoalesceUnchangedUpsertsis on.A parseable-but-non-canonical bound equal to the stored instant —
"2100-06-01T00:00:00Z"against a stored"2100-06-01T00:00:00.000Z"— compared as “unchanged” with coalescing on, so the write was skipped and theValidationErrorthe same call raises with coalescing off was swallowed. An unrelated performance flag decided whether malformed input was reported.A non-canonical REQUESTED bound now counts as a window change, so it reaches the write path and raises identically either way. Re-stating a window in canonical form still coalesces, including against a driver that renders the stored value as an equivalent zoned string: only the stored side needs canonicalizing, because the requested side is held to canonical form by the write validation this no longer hides.
-
#394
6dd43c4Thanks @pdlug! - graph-merge: scope the edge repoint/dedupe fold to collisions repointing causedA TypeGraph store is a multigraph: nothing enforces uniqueness on
(from, kind, to),create()makes a parallel edge, andgetOrCreateByEndpoints()is the opt-in set-semantics accessor. The merge’s edge fold nevertheless grouped every staged edge by(from, kind, to)and collapsed each group onto its lowest-sorting edge id, so a branch that created a parallel edge lost one of the two rows — and which one it lost depended on how the branch-created id happened to sort against the existing one.The fold is now restricted to what it was designed for. It groups staged edges by the endpoint pair they named before repointing, and collapses one row per pair — so a collision the canonicalization itself induced (
x → aandx → bboth becomingx → c*) still folds to a single edge, keeping the existing min-id-survivor, property-reconciliation, and end-of-validity behavior. Edges that already shared their endpoints are no longer folded together: each distinct edge id commits as its own parallel row, and a valid-time end lands on the row whose author claimed it rather than migrating to an unrelated survivor.A group that mixes the two folds only across the pairs. A repointed
x → bjoining two parallelx → arows merges into one of them, and the other row still commits with the edit its author made — repointing said nothing about the rows that were already there. Previously the whole group collapsed, which dropped that edit silently: a folded-away row is never rewritten.What makes two staged edges “the same row” is their edge id, not equal properties. One inherited edge staged by several branches still folds into a single write with its property disagreements reconciled; a branch-created edge is a new parallel row even when its properties coincide with an existing one’s.
Merges that previously collapsed parallel edges will now commit both, and the spurious
PropertyConflictthose collapses reported between two rows that were never the same row is gone. -
#406
248f56aThanks @pdlug! - Refuse valid-time windows of negative width. A write whosevalidToprecedes the row’s effectivevalidFromdescribes a row that stopped being true before it started — observable at noasOfcoordinate, and unrepairable by any later write — and it used to be accepted silently on every path except a node update. It now raises aValidationErrorwhose issue carries the new exported codeINVERTED_VALIDITY_WINDOW.This is a behavior change: writes that previously succeeded now fail. Two shapes refuse where they did not before.
- A stated
validFrom/validToPAIR must be ordered, on node and edgecreate,upsertById,bulkUpsertById,getOrCreateByEndpointsand its bulk form, and on an imported document.getOrCreateByEndpointsjudges the pair before its existence probe, so whether a call is valid no longer depends on whether the edge happens to exist yet. - An UPDATE’s lone
validTomust not precede the lower bound the row carries. Nodes already enforced this; edges did not, which is how a graph merge could hand a committed edge an end predating its start and still report success. This covers a resurrecting write too: an edge RETAINS itsvalid_fromacross resurrection, so reviving one into a window that closed before it began now means restating the start — passvalidFromalongsidevalidTo. Landing a revived edge in the ENDED state is otherwise unchanged.
getOrCreateByEndpointsand its bulk form now honorvalidFromon the"resurrected"branch, where they previously accepted it and silently dropped it — which is what left the refusal above with no way to satisfy it. As the backend has always documented for a resurrecting write, namingvalidFromasserts the COMPLETE window, so an accompanyingvalidTois applied and an omitted one REOPENS the revived row rather than leaving the tombstoned incarnation’s end in place. A"found"or"updated"live edge is unaffected: its stored lower bound is history and still stays put.Interchange import records the refusal as a per-row error prefixed with
INVERTED_VALIDITY_WINDOW, so one bad row does not abort the import; itsonConflict: "update"legs are held to the existing row’svalid_fromexactly as a directupdateis. Trusted import refuses the whole stream with reasoninvalid_stream.Two shapes stay legal, deliberately. A ZERO-width window (
validTo === validFrom) is what a same-instant retraction produces at millisecond precision, so the store’s own output still round-trips. An INSERT carrying a lone historicalvalidTostill means “born already ended”: the write instant stamped asvalid_fromis a storage convention rather than a caller assertion, and such a row is read back throughincludeEnded. - A stated
-
#380
2c5dd29Thanks @pdlug! - Store the cause of an identity assertion’s ending instead of deriving it.The identity assertion relation and its recorded mirror gain nullable
ended_by_kind/ended_by_idcolumns. A node soft-delete cascade stamps the deleted node’s(kind, id)onto every assertion it ends, in the same statement that closes the row;NULLmeans the row was retracted explicitly. Graph merge’sRetractionCausenow reads that column instead of comparing an assertion’svalid_toagainst a deleted endpoint’sdeleted_at.This removes the derivation’s same-millisecond residue: a retraction issued in the same millisecond as the delete that followed it is now classified as
explicitand survives a merge whose deletion is overruled, where the timestamp comparison could only read the tie as a cascade and drop the branch’s intent. The hard-delete residue remains by design — a hard delete removes the assertion rows outright, so no evidence survives to read.Archival interchange carries the cause as an optional
endedByon each assertion, so an export/import round-trip preserves why an assertion ended. Import rejects anendedByon an open assertion (IDENTITY_IMPORT_ENDED_BY_WITHOUT_END) or one naming a node that is not an endpoint of the assertion (IDENTITY_IMPORT_ENDED_BY_NOT_ENDPOINT); a CHECK constraint on the relation backs both rules at the database.Operational Identity has not shipped in a release, so the relation changes shape with no migration path.
-
#268
9721ba2Thanks @pdlug! - Add the opt-in TypeGraph Identity Profile with typed store, transaction, and temporal-view APIs; configurable same-ID folding or assertion-only identity; kind-branded and hydrated member reads; idempotent assertion receipts; ended assertion retraction results; assertion history; interchange and graph merge propagation; identity-expanded traversal; cross-backend closure storage; and fail-fast capability errors for non-transactional D1 and neon-http drivers.Harden ontology construction and reload validation: propagate disjointness through interleaved subclass and equivalence closure, validate inverse endpoint compatibility and partner uniqueness, reject unresolved extension edge names in
inverseOfandimplieswhile retaining absolute external IRIs, recompute serialized closures, and deprecate the type-levelsameAsanddifferentFromfactories in favor of Operational Identity.Behavior changes. Ontology and registry validation is now stricter and runs both at graph construction and when a persisted schema is loaded, so a few patterns earlier versions silently accepted now throw a
ConfigurationError: duplicate ontology relations, hierarchical self-loops, disjointness contradictions (a kind disjoint with itself, with a subclass ancestor, a common subclass of two disjoint parents, or a kind declared bothequivalentToanddisjointWith), multiple distinctinverseOfpartners for one edge, inverse endpoint incompatibility, and unresolved extension edge names ininverseOforimplies. To recover, fix the graph definition; for a persisted extension document, correct the stored document before upgrading (or rewrite it through the previous minor, which still accepts it). Interchange documents remain readable across versions —1.0documents are still accepted on import, and exports write2.0. Trusted import rejects identity-enabled target stores (identity_unsupported) and identity-bearing input (invalid_stream) rather than silently dropping assertions or leaving the derived closure empty; useimportGraphStreamfor an export that carries identity truth. Bundled SQLite and PostgreSQL backends provision the three identity relations, including effective customSqlSchemanames, before first-enable preflight. An already-enabled graph with missing identity storage instead fails withIDENTITY_STORAGE_MISSING; restore missing ledgers from backup, or recreate a missing derived closure and rebuild it before serving traffic.create()/upsertById()of a soft-deleted same-(kind, id)row now resurrects that row on every graph (properties replaced, validity window reset sovalidFrombecomes the resurrection instant) rather than leaking a storage constraint error. These are additive-strictness and semantics-pinning changes on top of the new opt-in profile, hence the minor bump.Type-level breaking notes for backend and tooling authors.
ResolvedSqlTableNamesgained three required fields (identityAssertions,recordedIdentityAssertions,identityClosure). Out-of-treeGraphBackendimplementations must supply them; theSqlTableNamesinput type keeps these optional, so only the resolved type is total.SqlSchema(the abstract class) gained three abstract members (identityAssertionsTable,identityClosureTable,recordedIdentityAssertionsTable). External subclasses must add them; thecreateSqlSchemafactory path is unaffected.FORMAT_VERSION’s literal type changed from"1.0"to"2.0". Comparisons likeFORMAT_VERSION === "1.0"are now type errors; both versions remain accepted on import.
Behavioral note.
revisionNow()now returnsPromise<RecordedInstant | undefined>(a branded string, assignable tostring; useasRecordedInstantto round-trip).Review-hardening pass (same release).
store.identityand the read-only viewidentitysurfaces now use the same conditional presence astx.identity: the property does not exist on identity-disabled graph types, so misuse is a compile error. TheIdentityFacadeFor/IdentityReadFacadeForhelper aliases and the duplicateIdentityNodeReftype are gone (useIdentityFacade,IdentityReadFacade, andGraphNodeReference); the loose input type formerly namedGraphNodeRefis nowIdentityNodeRefInput.StoreViewandRecordedStoreVieware now type aliases over an implementation class plusViewIdentityAccess, exported alongside a construction-compatibleconst.new StoreView(...)andinstanceof StoreViewkeep working; subclassing them does not.MergeReport.mergedgained anidentity: { asserted, retracted }section (MergedCounts), andDroppedItemis now a discriminated union (kind: "node" | "edge" | "identity") so dropped identity assertions are enumerable in the report.- Identity merge conflicts — including transitive
same/differentcontradictions, retract/reassert races, and assertions over merge-deleted nodes — are detected at plan time and surface asIdentityMergeConflictError(GRAPH_MERGE_IDENTITY_CONFLICT) throughmerge()’s returnedResult. Convergent edits (the re-asserting branch itself also retracted the pair) merge cleanly. ImportError.entityTypewidened to"node" | "edge" | "identity"; identity import failures are recorded inresult.errorsinstead of throwing. Archival identity imports now bound validity windows (validTomust not be in the future for ended rows,validFrommust not be for open rows) withIDENTITY_IMPORT_FUTURE_VALID_TO/IDENTITY_IMPORT_FUTURE_VALID_FROM.- Changing
identity.sameIdAcrossKindsis now classified a breaking schema change requiring explicit migration; explicitmigrateSchema()rebuilds the identity closure atomically with the schema commit, and an unapplied identity-only breaking change surfacesIDENTITY_PROFILE_MIGRATION_PENDINGrather than a genericMigrationError.
Performance. Current-coordinate identity reads (
membersOf,areSame,areDifferent,representativeOf,nodesOf) were O(total graph size) on SQLite — the class-members lookup defeated the closure’s class index and the planner scanned every live node per read. The rewritten statement is O(class size): ~40x faster on a populated graph (0.013 ms vs 0.56 ms per read at ~6,000 nodes), with a smaller improvement on PostgreSQL.Follow-up hardening (same release).
ValidationIssuegained an optionalassertionIdfield carrying the offending identity assertion structurally; identity import failures (self-assertions included) attribute theirresult.errorsentries by that id, never by message parsing. The identity enablement preflight is derived insideinitializeSchema()itself, so every public first-commit path — bareensureSchema/initializeSchemaincluded — builds and validates the closure atomically with version 1. Identity reads onincludeTombstonesviews hydrate soft-deleted rows the coordinate makes visible instead of silently dropping them.Import error attribution. The import coordinator tags rethrown errors with the id of the assertion it was applying, so
ImportResult.errorsattribution for contradictions and missing endpoints identifies the failing assertion rather than the first assertion sharing its endpoints.The identity preflight is not substitutable.
initializeSchema()andSchemaManagerOptionsno longer accept a schema-commit preflight callback — a no-op callback could suppress the mandatory closure build at version 1. Both (andMigrateSchemaOptions) instead accept the effectiveSqlSchema(schema), and every identity-enabled schema commit derives the closure preflight internally from it.One schema source in the batteries-included constructors. The nested
schemaManagementoption no longer acceptsschema(typed out and stripped at runtime): the effectiveSqlSchemahas exactly one source,store.schema, which also drives physical table provisioning — a second schema could name tables that were never created. The manager brand-validates theschemaoption withrequireSqlSchema()before any DDL or version commit, so a schema-shaped plain object is rejected (INVALID_SQL_SCHEMA) instead of committing a closure into tables the Store never reads.Historical bridges must exist; plan-time simulation knows the profile. Archival identity imports now require every ended assertion’s endpoints to exist structurally (soft-deleted rows qualify; the store’s own exports already satisfy this), so a hand-built document can no longer conduct historical identity through a node that never existed. The graph-merge plan-time contradiction check now simulates the target’s identity semantics — implicit same-id folds under
sameIdAcrossKinds: "fold"and ontologydisjointWithbetween class member kinds — so those contradictions surface asGRAPH_MERGE_IDENTITY_CONFLICTat plan time instead of a generic commit failure. Counterfeit schema objects are rejected before any identity DDL runs, on fresh and already-enabled graphs alike.Assertion-free nodes join the plan-time simulation. The merge planner’s contradiction check now seeds its universe with every post-merge canonical node and the live target peers sharing their ids (one kind-free indexed probe, only under
sameIdAcrossKinds: "fold"), so a node no assertion names — newly created, retyped, or an existing same-id peer — can no longer fold into a disjoint-kind class undetected and fail at commit as a generic merge error.Universe seeding, precisely. The plan-time simulation seeds retyped canonical nodes under the kind the commit writes (not their pre-retype kind), the live same-id peer probe reads the merge TARGET when it differs from the diff source (
mergeAgainstBase,mergeIncremental), and the incremental commit revalidates the probed peer set inside its transaction — a same-id peer landing in the plan→commit window is refused as the same typed replan error the other window guards raise.The window guard ranges over the committed plan. The incremental fold-peer revalidation compares only ids the final plan folds on — commit-ready canonical nodes and remapped assertion endpoints — so a window row at an id canonicalization dropped is tolerated as an ordinary target advance instead of raising a spurious replan error.
The window guard is class-transitive. The incremental fold-peer guard also snapshots each final seed’s structural identity class at plan time and revalidates the fingerprints inside the commit transaction — a window row or assertion that joins a seed’s class through another member (leaving the seed’s direct same-id peers untouched) is refused as the typed replan error, and a rerun surfaces the contradiction as a plan-time
GRAPH_MERGE_IDENTITY_CONFLICT.A validated baseline, exactly. The incremental identity guard now re-probes and snapshots the final seeds’ classes AFTER planning and re-runs the identity simulation against that exact snapshot — its members join the simulation universe unlinked, with connectivity rebuilt from the deletion-filtered fresh ledger and fold unions — so drift landing between planning and the snapshot fails as a typed plan-time conflict instead of becoming the guard’s baseline. Fingerprints are structurally encoded (injective for ids containing any character) and carry a liveness bit, so a planned assertion endpoint deleted in the commit window is refused as the typed replan error rather than failing generically.
Negative truth in the baseline. The post-plan identity recheck consumes the target’s FRESH assertion ledger (not the pre-planning staging capture), and the transaction guard carries a deterministic fingerprint of the
differentassertions touching the guarded universe — adifferentcommitted in either window is refused typed instead of surfacing as a generic commit failure.The identity guard covers both profiles. The incremental identity baseline, class/liveness fingerprints, and negative-ledger guard run for every identity-enabled merge — under
sameIdAcrossKinds: "ignore"too, where explicit assertions still change plan legality. Only the same-id fold expansion stays profile-gated; the plan-time simulation additionally models the profile-independent create-time constraint that one id cannot be shared by ontology-disjoint kinds, and the direct-peer window check refuses a disjoint same-id arrival under"ignore"while tolerating a benign one.Replacement is legal. Planned node deletions are excluded from both sides of the incremental identity guard (peers, liveness, class members, and the ledger slice), and
applyMergePlansoft-deletes nodes BEFORE the node writes — so a plan replacing a node with a disjoint same-id one (the order the create-time constraint permits, and the order the same operations run directly on a store) commits instead of being falsely rejected or failing at apply.Deleting a bridge splits the class. The incremental recheck derives connectivity from the deletion-filtered fresh ledger and the checker’s fold unions — never by pre-linking the old closure’s filtered member lists — so a plan that deletes an identity bridge and asserts its former ends
differentcommits instead of being falsely rejected. Snapshot class members still join the simulation universe (unlinked) so fold links at unprobed ids keep participating.The transaction re-derives legality. The incremental commit guard’s final step re-runs the full identity simulation on transaction reads — fresh deletion-filtered ledger, snapshot members, fold unions — so drift that leaves every fingerprint unchanged (a redundant
same(a, b)that becomes the surviving link once the plan removes the pair’s bridge) is refused as the typed replan error instead of failing generically at apply.One assertion id, one truth — validated where it can be typed. The planner refuses one id staged for two different complete truths and any staged id already identifying different truth among the target’s stored rows (ended included, exactly the set the import coordinator compares); the commit transaction revalidates every planned id against transaction reads (both commit modes), so a window row reusing a planned id — even with endpoints entirely outside the guarded universe — refuses as the typed replan error instead of a generic id-conflict at apply.
Retractions carry their complete truth. A merge plan’s identity retractions are full expected rows, never bare ids: the planner validates each one against the row its id identifies on the target and SKIPS — reported as
identity:retraction-target-mismatchindropped— a retraction whose id the target reuses for different truth, instead of ending a row the branch never saw. The commit transaction revalidates the surviving retractions (and every planned assertion id) by id in BOTH commit modes; snapshot commits need this explicitly because the legacy base@V token fingerprints only CURRENT assertions, so an ended window row claiming a planned id would otherwise slip through to a generic apply failure. The raw staged assertions are also checked one-id-one-truth BEFORE the semantic survivor dedupe, closing the validity-only collision (same id, same pair, differentvalidFrom) that dedupe used to collapse silently while the report listed the id as both applied and dropped.The applier is the completeness backstop, typed. Any identity refusal that still escapes the commit — an invariant the plan-time simulation does not (yet) mirror — is translated into the typed
IdentityMergeConflictErrorwith the applier’s error as its cause, instead of surfacing as the generic merge wrapper. Identity-typed environment errors (missing profile, non-atomic backend) pass through unchanged. A property-based law suite additionally quantifies the merge contract over randomized identity histories on both backends: refusals are always typed, a committed ledger is internally consistent, pre-merge truth survives unless a branch retracted it or deleted an endpoint, and the report never lists an id as both dropped-as-duplicate and newly current.Truth replacement is visible to the diff. The identity diff compares ids present on both sides by COMPLETE truth, not presence: a branch that hard-deletes an assertion’s endpoint (physically removing the row), recreates it, and imports the same id for different truth used to diff as empty — the merge silently kept the base truth the branch had replaced. The replacement now stages as a retraction plus a new assertion, and because the applier never reuses an ended row’s id, the merge refuses typed instead of silently preserving either side.
Identity semantics extracted; translation at the applier boundary. The plan-time identity derivation, contradiction simulation, and commit guards now live in
graph-merge/merge-identity.tswith a one-directional dependency from the merge orchestrator (functions take a structuralIdentityPlanSlice, never the full plan type). The typed-conflict translation wraps exactly the identity-apply call inside the commit, so it also classifies refusals whose identity code lives in nested validation issues (details.issues[].code) and — because only identity rows are applied at that boundary — a missing-node error there can only mean a vanished assertion endpoint, which now translates too instead of surfacing as the generic wrapper. Exact-duplicate staging (two branches importing one identical row) no longer reports the id as dropped while applying it.Five laws, three lanes. The property suite now also holds every successful merge to BRANCH-EFFECT accounting — every truth a branch holds is applied with equal complete truth, enumerated as dropped, retracted, or invalidated by an endpoint deletion; silent loss is a law violation — and runs the whole law set in three lanes: snapshot
merge()under both identity profiles (with hard-delete/recreate and same-id fold peers in the operation alphabet) andmergeIncremental()against a target that ADVANCED after the fork, where branch truth meets independently-moved target truth. Truth-preservation and branch-effect exclusions are truth-aware: a retraction excuses a row’s death only when the retracted COMPLETE truth matches, and a hard-delete/recreate excuses exactly the rows it physically killed, not everything ever touching the node. A dropped-as-duplicate id must never be current post-merge. The generator skips only expected semantic refusals (contradiction, missing node); any other error fails the run rather than silently emptying the histories. Independent-target merge semantics are now documented in the identity guide.The survivor pick respects committed truth. The law suite caught its first live defect within a day: a branch-minted assertion id could win the semantic-pair dedupe against the target’s own committed row — the applier (idempotent per pair) then skipped the write, so the report claimed an id as applied that never landed while listing the target’s committed row as dropped. Ids already committed on the target with the exact staged truth now always win the survivor pick, pinned by a deterministic incremental test alongside the law.
The simulation uses the plan’s REAL canonical map. Both closure re-runs (post-plan and in-transaction) previously reconstructed the member→survivor map from the report-shaped resolutions, which drops pure ontology-retype clusters and mis-keys mixed-kind members — degrading the decisive in-transaction backstop into judging endpoints at pre-merge identities (a false negative) and enabling an unresolvable replan loop (a false refusal). The plan now carries the exact
canonicalOfmap the commit repoints edges with, and the reconstruction is deleted. The simulated base ledger is also deletion-filtered inside the checker itself, so all three call sites share one post-deletion rule.An overruled deletion no longer ends identity truth. A node soft-delete cascades — it ends every open assertion touching the node — so the deleting branch’s diff stages those endings as retractions indistinguishable from intent. When the delete/modify resolution keeps the modification (the default
"flag"and"modifyWins"policies), the node survives, and the cascaded retraction is now dropped with it — reported asidentity:deletion-overruled— instead of ending the resurrected node’s assertions anyway.Identity-only merges advance the revision clock. The interchange import records capture touches through its own recorded binding, so a merge whose only effect was creating assertions never marked the mutation as written: the durable revision clock stayed unmoved and every base@V token went stale, letting a later commit’s target-unchanged guard pass against a target that DID move. The apply now marks the write from the import summary, with a regression test on a revision-tracking store.
Guard structure hardened. The by-id freshness check is invoked directly by BOTH commit paths (never through the peer-probe guard’s early return), the environment-code passthrough covers the identity environment/corruption codes that must never be translated into replan advice, and one id staged as both a new assertion and a retraction — an applier-refusing shape currently unreachable through any supported staging path — refuses typed defensively at plan time.
External-review hardening (cross-model pass). An independent review with a different model produced six verified fixes: (1) the deletion-overruled retraction filter is provenance-aware — a retraction is dropped only when EVERY contributing branch is explained by an overruled endpoint deletion, so a branch that retracted independently keeps its effect (the earlier filter silently suppressed it); (2) committed-row precedence in the survivor dedupe is RE-DERIVED after endpoint canonicalization, closing the collision the first fix missed when reconciliation collapses a branch pair onto a committed target pair; (3) merges refuse, typed, any branch whose store ran a schema operation after forking (its committed schema hash no longer matches the fork source’s) — schema side effects can no longer be smuggled into a data merge as bare identity changes; (4) a kind-dropping
migrateSchema()now cascades the assertion ledger exactly asStore.removeKinds()does, instead of stranding current assertions on unregistered kinds where a later “no-op” merge would end them; (5) a staged survivor’s valid-time window travels with the commit write, so a branch-authored — possibly already ended — window survives resurrection instead of being reset to merge time and silently joining a live fold class; (6)merged.identityreports rows the applier actually created and ended (idempotent skips excluded) instead of planned intents, and the replan-vs-conflict error suggestions are path-specific. Temporal windows on MODIFIED inherited nodes remain outside merge state — a documented boundary.Second cross-model pass: the fixes’ own compositions. A follow-up external review of the previous round’s fixes produced seven more verified corrections. The schema-drift guard now anchors on the branch’s AT-FORK
(version, hash)row — a round-trip migration that restores the document hash still advances the monotonic version and is refused, and unmanaged fork sources are no longer falsely rejected; revision-anchoredbase@Vtokens bake in the active schema version, fencing the same round-trip on the target side (the legacy content fingerprint already covered it). Kind-dropping schema operations cascade the assertion ledger even when identity is DISABLED at drop time (the ledger, not the schema profile, is the signal), and first enablement purges assertions naming unregistered kinds, so historical orphans cannot be adopted into a fresh closure. Node resurrection carriesvalidFromthrough the internal update path (a branch-authored ended window no longer inverts into merge-time-start), merged edges carry their staged windows exactly as nodes do, and when the live incremental target itself contributed the surviving member, the TARGET’s committed window wins over a branch re-window. Canonicalization that would move a COMMITTED assertion’s own endpoints refuses with a specific typed conflict (committed rows cannot be rewritten), and window-identical upserts coalesce again instead of rewriting version and history state.Final pre-merge pass. A last scoped external review of the previous hardening commit returned three refinements, all applied: the disabled-identity cascade’s outside-transaction emptiness probe is skipped when THIS commit is the one disabling identity (writers on the still-enabled prior schema could otherwise slip an assertion in between probe and lock — the locked cascade always runs for that shape); node writes validate the EFFECTIVE validity lower bound, so a lone historical
validToon a resurrecting upsert refuses typed instead of persisting a born-inverted, permanently invisible window (edge resurrection keeps its sanctioned resurrect-as-ended contract — edges retain their stored lower bound, so the node-side corruption cannot arise there); and bulk edge coalescing compares explicit windows against the stored window, so no-op incremental merges stop rewriting byte-identical target edges. The property law lanes carry explicit five-minute test budgets sized for coverage-instrumented CI shards. -
#375
fc6075dThanks @pdlug! - The merge commit proves its own identity result. After a merge commit’s identity DML, and inside the same transaction, the applier now re-derives the identity classes the merge TOUCHED and refuses a contradiction there — a class whose member kinds the ontology declares disjoint, or a currentdifferentassertion whose endpoints share a class. Both commit modes run it, seeded from the planned assertion and retraction endpoints plus (undersameIdAcrossKinds: "fold") the node identities the commit writes, so the cost is proportional to the affected classes rather than the graph.This makes a committed identity ledger correct independently of the plan-time simulation, which reasons about state read before any write. The simulation and the commit-window fingerprints remain as the diagnosability layer: they refuse early, before anything is written, naming exactly what drifted.
Because the scans resolve classes through the materialized closure — the same authority every current identity read uses — a closure that lags its ledger can hide a contradiction as easily as invent one. On any inconsistency the closure is rebuilt from the base relations inside the commit transaction and the scans re-run against it: a clean second pass means the closure was stale and is now repaired atomically with the merge, while a repeated contradiction aborts the whole merge. There is no partial commit either way.
IdentityContradictionErrorDetails.operationgained a"merge"member for this refusal, which reaches callers as the existingIdentityMergeConflictError(GRAPH_MERGE_IDENTITY_CONFLICT) with the contradiction as its cause.
Patch Changes
Section titled “Patch Changes”-
#418
5795127Thanks @pdlug! - store: coalesce a bulk upsert that re-states a row’s own validity windowbulkUpsertByIdnow decides whether a requestedvalidFrom/validTois a change the same wayupsertByIddoes, through one shared comparison, so a batch and the same items applied one at a time write the same rows.Two defects met in that comparison. The node bulk path refused to coalesce whenever an item named a bound AT ALL, so any caller that re-stated a row together with the window it already holds — a merge commit, or any read-modify-write loop that round-trips
meta.validFrom— bumped the row’s version and wrote a history and revision entry for a row that did not change. The edge bulk path did compare, but compared the bounds as DRIVER TEXT: a Postgres driver that renderstimestamptzas a zoned string rather than aDateyields text that is equivalent to the caller’s canonical ISO bound without being identical to it, so the same batch could coalesce on one backend and write on another. Both paths now compare INSTANTS, and an unrepresentable bound still counts as a change so the write path raises theValidationErrorthe caller is owed rather than coalescing it away.The bulk paths also track the window each queued write leaves behind, so a repeated id in one batch is compared against the batch’s own pending state rather than the once-prefetched row. Previously an edge item that re-stated the window the row held BEFORE the batch was read as unchanged and skipped, dropping a write the sequential path performs. A later copy that re-states the window a queued write established coalesces; one that names a bound the backend was left to stamp (an omitted
validFromon a create) writes, since that instant is not knowable batch-locally. -
#363
cdc904bThanks @pdlug! - Chunk iterative graph-algorithm node-kind initialization within each backend’s bind-parameter budget. -
#396
994c7daThanks @pdlug! - Reach the candidate edge of a current-coordinate identity-expanded traversal by an equi-join instead of a correlated membership scanAn identity-expanded hop at the current coordinate read the materialized closure from inside a correlated
EXISTS, so nothing in the join condition linked the frontier row to the edge row. Both engines were free to enumerate frontier rows × edges of the matching kind and probe the closure per pair, which cost quadratically in graph size.Each traversal step now widens its frontier onto the closure’s class members with an outer join, so the candidate edge is reached by the same ordinary indexed equality a traversal without identity expansion uses. One compiler path serves both coordinates and both emitters. On SQLite a hop over 100,000 matching edges from a 500-row frontier drops from 51.6 s to 77 ms, and
EXPLAIN QUERY PLANseekstypegraph_edges_from_idxwhere it used to scan every matching edge per source row; PostgreSQL drops from 9.2 s to 61 ms.A traversal at a historical coordinate reaches its candidate edge through the same step, so it gains the same join order: on SQLite an
asOfhop over 100,000 matching edges drops from 31.3 s to 241 ms. That coordinate’s own remaining cost is the ledger reconstruction, still tracked in typegraph#310.Results are unchanged at every coordinate: physical edges stay deduplicated, and member visibility, the
sameIdAcrossKindsprofile and the read instant are all resolved exactly where they were. The class members a current-coordinate step joins are reached by seeking the closure from the frontier row, so the cost of the widening tracks the frontier and its classes rather than the identity population — see the follow-up changeset, which replaced the graph-wide relation this change first shipped with that seek. -
#400
05af68dThanks @pdlug! - graph-merge: record each provenance contribution once, so the persisted count is the rows actually writtenSeveral planning phases legitimately observe the same
(role, canonical, branch, source)contribution. An inherited edge is credited once when its modification survives delete/modify and again when the repoint fold reads it as a source, and a fold set’smergedIdscarries one entry per staged copy — so a row staged by several branches re-offered each of its branches once per copy. The tuple is exactly the sidecar row’s identity, so those re-observations were never new information: they inflatedprovenancePersisted.count, and because a singlebulkUpsertByIdbatch cannot create the same id twice, the over-count was the milder half: withpersistProvenance: true, a merge in which a single branch modified one inherited edge failed the whole best-effort persist, soprovenancePersistedcame back absent, aprovenance persistence failed …warning was reported, and NO provenance rows were written at all.Contributions are now collapsed at the single recording funnel, so the record list, the in-memory
provenance.byBranchindex and the reported count all speak about distinct contributions.persistProvenanceRecordsadditionally collapses records that hash to one id before the batch, which makes its documented “row count written” true for any caller’s record list. Every genuinely distinct contributing branch is still credited. -
#384
cc7af7bThanks @pdlug! - Report the real active schema version inStaleVersionError.details.actualwhen a PostgreSQL schema-managed write loses to a concurrent schema commit.The write fence takes a
FOR SHARElock on the active schema row. Atread committed, a locking read that blocks behind an in-flight schema commit rechecks only the row versions its own statement snapshot saw — so once the winner marked the old row inactive, the fence saw no active row at all and reportedactual: 0, misrepresenting the database as having no active schema. The fence now settles an empty locked read with a non-locking read, which observes the committed winner, and reports0only when a graph genuinely has no active version. The write itself was always correctly rejected; only the error metadata was wrong. -
#427
facef56Thanks @pdlug! - Bound current-coordinate identity expansion by the frontier instead of the identity populationAn identity-expanded traversal at the current coordinate built its class relation by self-joining the whole identity closure into a materialized CTE, before any frontier predicate applied. The relation’s size is the sum of the squares of every class in the graph, so a hop from a single start row paid for identity classes it never touched: nine unrelated classes of 501 members materialize 2,259,009 seed/member pairs, and the hop measured 564 ms on SQLite and 568 ms on PostgreSQL where the equivalent traversal without expansion costs microseconds. Doubling an unrelated class quadrupled the cost.
Each step now seeks the closure from its own frontier rows — the frontier row’s class through the closure primary key, that class’s members through the class index, each member’s node for its visibility — so the peer relation is never built for classes the query does not touch. The same hop measures 0.5 ms on SQLite and 2.4 ms on PostgreSQL, and PostgreSQL’s
EXPLAIN (ANALYZE)reports 18 rows visited against 4,522,557. A single-start-row hop over 50,000 folded triples drops from 387 ms to 1 ms on SQLite. Wide-frontier hops are unchanged: 500 source rows over 100,000 matching edges measures 325 ms against 331 ms, because that shape was already paying for a population it used.The historical coordinate keeps its hoisted, materialized relation. Its rows come from a recursive fixed point over the assertion ledger that no frontier row narrows, so evaluating it once per statement is still the win, and the two coordinates are now deliberately different strategies behind one interface rather than one relation with two sources. Both remain a single compilation path across dialects.
Results are unchanged at both coordinates: physical edges stay deduplicated, member visibility is still resolved against the read instant, and a frontier row in no class still expands to itself.
-
#391
8eafebdThanks @pdlug! - Evaluate the historical identity-class reconstruction once per query instead of once per candidate edgeAn identity-expanded traversal hop under a historical coordinate (
asOf,asOfRecorded, or a non-currentview()) has no materialized closure to read, so it rebuilds classes from the assertion ledger. That rebuild used to sit inside the correlated edge predicate, where SQLite re-materialized it for every candidate (source row, edge) pair, and undersameIdAcrossKinds: "fold"each rebuild also scanned the structural same-id relation across the graph — a quadratic term that quadrupled per doubling of graph size.The reconstruction is now a single materialized query-level relation of
(seed_kind, seed_id, kind, id)rows, seeded by the nodes that have identity peers rather than by the frontier, so it depends on nothing a traversal step carries and is built once for the whole statement. Each step widens its frontier onto that relation with an outer join, which turns the candidate-edge lookup into the same ordinary indexed equality a traversal without identity expansion uses. On the narrow-edge fixture (SQLite, all n nodes acting as source rows) the hop drops from 122/486/1984/8261 ms at n = 250/500/1000/2000 to 7/7/14/28 ms, and grows linearly rather than quadratically.Results are unchanged at every coordinate. Current-coordinate traversal still reads the materialized closure through its existing correlated predicate.
-
#425
92354bfThanks @pdlug! - Ask props bags whether they carry a key withObject.hasOwnrather thanin.A props bag is data: its keys come from a JSON column, so a schema may declare a field named after an
Object.prototypemember —toString,constructor,valueOf— and such a field is ordinary data that survives Zod validation and the JSON round-trip untouched.incannot answer “does this row carry this property” for such a bag, because"toString" in {}istrue: a row that does not carry the key reads as though it does, and the read that follows yields the inherited prototype member instead of stored data.This is a lost-write fix, not only hardening. In a graph merge, a fork’s bag is its full intended state, so a base property absent from it was deleted by that fork. Under
inthat deletion was never detected for a prototype-named field: no deletion tombstone was written and the base value survived the merge, silently discarding the fork’s write. The same misclassification credited a branch that does not carry such a property with the inherited prototype member as if it were a stored value, letting an invented claim compete in conflict resolution and be reported to the caller as that branch’s value. A schema diff also reported a removed prototype-named property as an incompatible schema change rather than a removal, because the absent field resolved to a function that was then compared as though it were the field’s new schema.The edge fold and the node cluster union were affected in the same way, and their worst outcome was a committed function. For a property the SURVIVING row does not carry, both ask that row’s bag for the value to keep, so under
inthey took the inheritedObject.prototypemember and wrote that function into the merged row instead of the value a member actually carried. The cluster union additionally routed such a property through the separate base-property-conflict policy on the strength of a base member that does not carry it, so the wrong policy decided the committed value. The edge fold’s claim filter separately counted a member that says NOTHING about such a field as having AUTHORED it; the shared value collector discarded that phantom claim, so the two agreed only by one absorbing the other’s mistake.Two guards were quietly weakened rather than corrupted. Graph-extension validation accepted a unique constraint on an undeclared field named after a prototype member — it answered “declared” against the prototype — and went on to index a field that does not exist. The evolve guard that refuses re-adding a kind whose data cleanup is still pending never counted such a kind as added, so it skipped the refusal.
The convention now has one owner,
hasOwnKey, applied across graph-merge node and edge property resolution, schema-diff property classification, schema-removal reconciliation, interchange unknown-property stripping, graph-extension document validation, query and index schema-field validation, the evolve pending-removal guard, edgematchOncomposite-key and match-comparison reads, and embedding/fulltext field extraction.inremains correct, and still in use, when both the key and membership question are internal: a discriminated union’s tag, a capability probe, a brand check, and the deliberateObject.prototypelookup in selective projection. A user-supplied field name is always checked as an own key, even when the schema shape itself is statically known, so names such as__proto__andconstructorcannot masquerade as declared fields throughObject.prototype.A plain
bag[field]walks the prototype chain exactly asfield in bagdoes, so the same misreading reached two more read paths that never usedinat all. An edge’smatchOncomposite key and its per-field match comparison (getOrCreateByEndpoints, edge upsert dedup) read a caller’s stored and input props by a schema-declared field name; a field named after a prototype member that neither bag carries as an own key now reads asundefinedon both sides instead of the same inherited function, so a match or non-match decision is never made on a phantom shared value.syncEmbeddingsandcomputeFulltextContentread a declared embedding or searchable field the same way, so an undeclared row no longer surfaces a prototype function as if it were the field’s stored value there either.thenandtoJSONcomplete the same class from the other end. They are the two names JavaScript itself probes — the thenable check and theJSON.stringifyhook — so every proxy standing in for a row resolved them toundefinedup front to stay safe to await and to serialize. They are also legal schema field names, and answering them by NAME before consulting the data made the read side lie: a declared field calledtoJSONcame backundefinedthrough smart selection while the full mapper returned the stored string, so the same query answered differently depending on whether the optimizer engaged — exactly the equivalence selective projection exists to preserve. The predicate builders (query, traversal, collection, and index WHERE) made such a field unaddressable outright, and field tracking dropped a declaredthen, so the projection could not have carried it.A declared
thenortoJSONfield is now tracked, projected, readable through smart selection, and usable in a predicate. The rule is the one the surrounding fixes already follow: ask the data question first —hasOwnKeyfor a materialized row,hasDeclaredFieldfor a proxy whose key set is the schema — and fall back to the probe exemption only once the answer is “not data”, which is what keepsawaitandJSON.stringifyworking on a partially projected row. Returning an ownthenis safe as well as correct: props decode from a JSON column, so the value can never be callable, and the thenable check ignores a non-callablethenexactly as it does on the plain objects the full path returns.isInteropProbeKeyowns which names those are, and an ESLint rule bans the bare name comparison that used to stand in for the decision.__proto__, the case originally reported, is the NARROW variant. Every VALIDATED write path blocks it: Zod drops an own__proto__key, andbag["__proto__"] = valueassigns a prototype rather than creating a key, so an assignment-built bag cannot carry one either. It is still reachable throughtrustedImportGraph, which by contract does not validate properties and writes a caller’s bag verbatim — the stored JSON parses back with__proto__as an own key on both dialects. Recorded here so the two are not confused: a prototype-named field needs nothing unusual at all, while__proto__needs the trusted path. -
#381
e6fb356Thanks @pdlug! - identity: answer current different-ness with one probe on the separation relationidentity.areDifferent()and theassertSamecontradiction precheck resolved both identity classes and then loaded every currentdifferentassertion touching one of them, scanning in JS for one that spanned the pair. That scan grew with class size and, past the backend’s bind budget, took more than one statement. Both now probe the derived separation relation on its primary key(graph_id, class_key_low, class_key_high)instead:areDifferentreads the assertion ledger not at all, and the precheck reads it only to name the conflicting assertion in the typed error it is already about to throw.Results and typed errors are unchanged. Reads at a valid-time
asOfor a recorded coordinate still reconstruct from the ledger, since the separation relation projects current assertions onto current classes. A probe never answers “not separated” when it could not read: a missing relation refuses withIDENTITY_STORAGE_MISSING, and any other driver failure propagates unchanged so transient conflicts stay classifiable. -
#404
479ca78Thanks @pdlug! - FixbulkUpsertByIdthrowing on a repeated id whose row does not exist yet.bulkUpsertByIdapplies items in order, so a repeated id in one batch is last-write-wins — but that only held for an id that already existed. The create branch queued its create without registering the id in the batch-local pending map, so a second copy of a new id queued a second create and the batch failed withNode already exists/Edge already exists(a unique-constraint violation on some paths). Callers feeding a batch straight from a stream or a changeset, where a key can legitimately appear twice, hit this on first delivery of a key.A queued create is now registered like a queued update: a later copy of the id takes the update path over the queued create, which runs after the batch’s creates, so the final row is exactly what the equivalent sequence of
upsertByIdcalls produces — the later copy’s props merged over the created row, one version bump per real write, and the created row’s validity lower bound. WithcoalesceUnchangedUpsertsenabled, a value-identical second copy of a new id now coalesces against the queued create instead of writing a second time. Nodes and edges are both fixed; for edges, as for an id that already existed, a later copy’sfrom/toare ignored because an update never repoints an edge.Two smaller consequences of routing every queued write through the same state: a repeated id whose dirty check rejected an earlier item’s props no longer reports the wrong error, and no later copy can coalesce against a stale prefetched row after an earlier item queued a write.
-
#362
9982960Thanks @pdlug! - Translate PostgreSQL read-only and missing-TEMPfailures during graph analytics intoUnsupportedBackendCapabilityError, preserving the driver error as the cause. Both refusal points are covered: a standby that rejects the read-write working-table transaction, and a role that cannot create the temporary table inside it. -
#420
d82fdafThanks @pdlug! - Harden two failures at the operations/backend boundary: a create the engine refuses now reports the condition it actually hit, and the last UPDATE path that could store an inverted valid-time window no longer can.A create refused by the engine reports “already exists”, not a raw driver error. A create learns an id is taken either from its own existence probe or from the engine refusing the INSERT, and the second used to escape as a
DrizzleQueryErrorwhose.messageis the raw INSERT text. One condition therefore surfaced as a typed user error down one path and an opaque system error down the other, and callers could not branch on it at all. The engine’s report is now classified structurally and both routes raise the sameValidationError, on the single and batch create paths for nodes and edges alike.Two things reach the engine’s path. A NODE create probes first, but the probe and the INSERT are two statements and PostgreSQL’s default READ COMMITTED does not serialize the two write transactions, so a concurrent create of the same new id can commit in between — the issue’s reproduction. An EDGE create has no existence probe at all, so the engine’s refusal is its only report of a taken id, on every backend and with no race involved.
Classification is structural, never message text: SQLSTATE 23505 plus the PostgreSQL protocol’s own constraint and relation fields, and SQLite’s extended result code, which distinguishes a primary-key duplicate (1555) from any other unique-index duplicate (2067) in the code itself.
Every such refusal, from either route, now carries the new exported issue code
ENTITY_ALREADY_EXISTS, so a caller can recognize it without matching on the message.details.entityTypeanddetails.kindsay what was refused;details.idnames the taken id, and is absent only when the refused statement inserted more than one row, because the engine reports that the statement collided without saying which row did. No race is needed to reach that: a bulk create of edges, whose ids the caller supplied and which nothing probes, is refused this way on every backend.The classification is scoped to the primary key on purpose. A
unique: trueindex declaration materializes a UNIQUE INDEX on the same relation, and violating that is a declared-uniqueness failure about the row’s VALUES rather than a duplicate identity — PostgreSQL reports it under the index’s own name and SQLite under a different extended code, so it never matches and is unaffected. Neither is a declareduniqueconstraint conflict, which still raisesUniquenessError.SQLite never reached the node race:
BEGIN IMMEDIATEgives the writer slot to one transaction at a time, so a second create cannot sit between its probe and its INSERT while the first commits. Its probe is authoritative there, and the refusal was already the typed error — it now carries the code too. A duplicate EDGE id on SQLite did surface as a rawSqliteError, and now raises the same error as it does on PostgreSQL.A node resurrection stores the bound its window guard measured against. A resurrection rewrites
valid_fromrather than retaining it, so the guard that refuses inverted windows has no stored bound to check and used the write instant instead — sampled in the operations layer, while the backend went on to stamp its own, strictly later, sample. AvalidToat the guard’s instant passed as zero-width and committed as NEGATIVE width a millisecond later, the exact shape the previous release exists to refuse. The operations layer now passes the instant it validated against explicitly, so the bound that is checked is the bound that is stored. Stating both endpoints is unaffected; the only change to a successful write is that a resurrection’svalid_fromis the operations layer’s instant rather than the backend’s — sampled a moment earlier inside the same locked write, before the uniqueness entries it re-checks and re-inserts.Edge resurrection was never exposed: an edge RETAINS its stored
valid_fromunless the write names a new one, so its guard measures against a value already on disk and predicts nothing. -
#403
c0279fcThanks @pdlug! - graph-merge: credit the branch that authored a merged row’s end-of-validityEnding a row’s validity is authored state, but a branch whose only change to a row was its window could contribute the instant the merge committed and still be absent from the merge’s provenance. An identity is staged once, so a branch that merely moved an inherited edge’s window had its staged copy skipped whenever another branch’s property edit already staged that edge — and the provenance for edges is derived from the staged copies. Nodes were worse: a window change had no provenance path at all, so a window-only node ending was credited to nobody even when no other branch touched the row.
The credit now comes from the window resolution itself, which is the only phase that knows whose claim was committed. It credits exactly the branches whose claim IS the resolved end — a claim that lost the least-claim rule contributed nothing to the committed row, and remains visible in
MergeReport.validityEndsunderclaimedBy. A branch that both edited a row’s properties and moved its window stays one contribution.The staged copy that carries a window-only ending is no longer credited for carrying it: that copy exists only to give the ending a row to write, its properties are the base’s, and the branch holding it is whichever sorted first — possibly one whose claim the merge discarded. Which branch carries the row is left exactly as it was, because that branch also labels the base’s properties in the repoint fold’s property union, where a relabelled contribution can change which value a fold commits. Merge outcomes are unchanged; only the provenance is.
0.45.0
Section titled “0.45.0”Minor Changes
Section titled “Minor Changes”-
#355
2882b23Thanks @pdlug! - Addstore.nodes.<Kind>.updateWhere()for typed, transactional set-based node updates selected by property and independent relationship predicates. The operation validates complete after-images and atomically maintains uniqueness, fulltext, vector, history, and revision state on SQLite and PostgreSQL. Its cross-backend storage primitive returns every updated after-image and provides bind-budgeted, graph- and concrete-kind-scoped uniqueness cleanup so rebuilding reservations cannot clear same-id nodes of another kind. -
#352
872d196Thanks @pdlug! - Addstore.repairContributions(), a privileged, idempotent repair pass for strategy-owned contribution storage. It re-audits declarations from the active persisted graph, non-destructively retriesmissing-markerandfailed-materializationfindings, reportsstaleandorphaned-markerasrequires-rebuild, and returns a fresh post-repair diagnostic result. Repair targets remain backend-owned so callers do not need access to TypeGraph-managed tables, physical names, or DDL. -
#353
c225605Thanks @pdlug! - Add Store-level heterogeneous bulk edge reads that keep database round trips independent of schema breadth.
Patch Changes
Section titled “Patch Changes”-
#351
ff8e428Thanks @pdlug! - Ensure the kind-removal status table beforeevolve()checks it, so databases created before TypeGraph 0.44 can evolve without manual backend initialization. Concurrent PostgreSQL focused-table ensures also retry the catalog uniqueness race thatCREATE TABLE IF NOT EXISTScan surface during replica startup. -
#350
f752543Thanks @pdlug! - Clarify that schema-managed Stores are immutable schema snapshots. Afterevolve()changes the schema, callers must use the returned Store or the updatedStoreRef.currentfor subsequent work; a previously captured Store is not mutated and its managed writes are rejected by the schema-version fence. Document how long-lived caches detect schema commits from other processes withgetCommittedSchemaVersion()and refresh through a verified Store open.Correct the
StoreRefcontract to say that the replacement is installed before a successful schema-changing call resolves, rather than claiming that the in-memory ref update is atomic with the persisted schema commit.
0.44.0
Section titled “0.44.0”Minor Changes
Section titled “Minor Changes”-
#331
a1f1fdeThanks @pdlug! - Add batched multi-source edge reads:bulkFindFrom/bulkFindToEdgeCollectioncould only read the edges of ONE endpoint at a time, so rendering a page of N nodes with their relationships cost N statements. The newstore.edges.<kind>.bulkFindFrom(froms, options?)andbulkFindTo(tos, options?)read a whole SET of endpoints in set-oriented statements per endpoint kind and bind-budget chunk, returning the edges grouped per input (indexiholds the edges of inputi, empty array when an endpoint has none).This widens the predicate rather than batching the calls:
from_id = ?becomesfrom_id IN (...), the same prefix seek on the edge relation’s system index. Temporal semantics are identical tofindFrom/findTo— same default mode, sametemporalMode/asOfoptions, same soft-delete filtering, same per-endpoint ordering — and aStoreViewexposes both methods pinned to its coordinate. PasslimitPerInputto bound each endpoint’s fan-out (applied in SQL viaROW_NUMBER()where the backend supports window functions). Inputs larger than the backend’s bound-parameter budget are split across statements transparently.Backend authors: this adds a new optional
GraphBackendoperation,findEdgesByEndpointSet(params), with its ownFindEdgesByEndpointSetParams.FindEdgesByKindParamsis unchanged.It is a separate operation rather than optional fields on
findEdgesByKindso that a backend which does not implement it cannot degrade silently. Optional params would have left an existing backend type-correct while it ignored the id list and returned every edge of the kind — which the collection would rebucket into a correct-looking answer at unbounded cost. Support is now detected by the method’s presence, before any read is issued, andbulkFindFrom/bulkFindTorefuse with a typedConfigurationErroron a backend without it rather than loopingfindFromper input.The parameter shape also makes the previously-validated illegal states unrepresentable: one
sideinstead of two id lists, no scalarfromId/toIdto disagree with a set, and nolimit/offset/afterto slice a read the backend splits into bind-budget chunks. -
#334
7a2e16bThanks @pdlug! - Addstore.verifyContributions(), an owner-agnostic diagnostic that crosses each contribution currently expected by the active graph and backend strategies against its durable marker and the physical catalog. Nothing on the open path probes the catalog — boot and the runtime asserts short-circuit on a per-instance signature cache and then on the marker row alone — so a database whose strategy-owned tables were dropped out of band opened completely clean and failed at the first fulltext or vector read. The diagnostic reports detected problems asorphaned-marker(marker records a success, table absent),missing-marker(table present, nothing attests it),failed-materialization(the marker records a failed attempt and no table was produced — marker and catalog agree, and it is broken anyway), orstale(marker recorded at a different shape), with theowner/logicalName/physicalNameand, for vector slots, thekindandfieldPathneeded to route to the state-specific repair without reconstructing internal marker strings. For vector slots,missing-markerandfailed-materializationuse the non-destructive forced ensure; onlyorphaned-markerandstalerebuild vector storage withstore.reembedVectorField.lastErrorcarries the reason the marker recorded, when it recorded one:statesays which repair to run,lastErrorsays why it broke. A contribution with neither a marker nor a table was never attempted and is omitted, as are retired markers and unsupported vector slots, so an empty result is not proof of initialization. It is read-only (one existence query per contribution table, no DDL, no writes) and deliberately not a boot step; the fast-path caching stays the default. Backends that cannot probe their own catalog throwConfigurationErrorrather than reporting a clean bill of health. -
#335
7950bb0Thanks @pdlug! - Support list-valued parameters inin()/notIn()field.in(param("ids"))now binds the whole list at.prepare().execute({ ids: [...] }), so the canonical “fetch these ids” query can finally be prepared. The list rides on a single bound parameter that the dialect unpacks (json_eachon SQLite,jsonb_array_elements_texton PostgreSQL), which keeps arity out of the SQL text: one compiled statement serves every list length, and a list of any size costs one bound parameter instead of one per element. An empty list is valid —in([])matches nothing,notIn([])matches everything.A
ParameterRefpassed among the elements of a literal list (in(["a", param("b")])) was previously coerced to a literal and silently produced wrong results. It now throwsUnsupportedPredicateErrornaming the supported form. A name used both as a list and as a scalar in one query is rejected atprepare().List elements are validated against the field’s type before binding, so
[1, "a"]against a number field is rejected with aConfigurationErrorrather than failing on PostgreSQL and silently matching nothing on SQLite. This matches the literal form, which already refuses a mixed list.Non-finite numbers (
NaN,±Infinity) are now rejected in any parameter binding, list or scalar.JSON.stringifyturns them intonull, so a list binding became SQL NULL —notIn(param("x"))with[NaN]filtered out every row — and SQLite binds a scalarNaNas NULL, soeq(param("x"))withNaNquietly matched nothing. Both now throw.DialectAdaptergains two members,inListParameterandpackListValue; custom dialect adapters must implement them. -
#329
03e87bdThanks @pdlug! - Export the committed-schema reads from the package root.getActiveSchema,isSchemaInitialized, and theSerializedSchematype now sit next togetCommittedSchemaVersionin@nicia-ai/typegraph, so answering “what kinds does this database already have?” no longer requires finding the@nicia-ai/typegraph/schemasubpath or queryingtypegraph_schema_versionsby hand.getActiveSchemaandgetCommittedSchemaVersionnow cross-reference each other in their docstrings. -
#332
2fb8925Thanks @pdlug! - FixmigrateSchema()silently dropping runtime-committed kindsmigrateSchema(backend, graph, currentVersion)committedgraphverbatim. It did not fold the persisted graph extension, so kinds committed at runtime byStore.evolve()— which live inschema_doc.extension, not in the compile-time graph — were erased from the active schema document while their rows stayed intypegraph_nodes/typegraph_edges, reachable by nothing. The persisteddeprecatedKindsset was erased the same way.This was reachable by following the library’s own advice: the
MigrationErrorraised for a breaking change told callers to “usegetSchemaChanges()to review, thenmigrateSchema()to apply”, and doing so with the graph they passed tocreateStoreWithSchemadestroyed everyevolve()-committed kind.Two changes:
- The persisted graph extension (and deprecated-kind set) is now folded in,
exactly as
createStoreWithSchemaandgetSchemaChangesalready did.migrateSchemawas the last commit path that did not. Callers pass the graph they have; runtime-committed kinds survive. - A commit that would drop a kind still holding rows is refused with a
MigrationErrorwhosedetails.reasonis the new"kind-removal"discriminant and whosedetails.droppedKindsnames them. Pass{ discardDroppedKindRows: true }if losing those rows is the intent — the name says what the flag does, because the next reconcile deletes them.
The guard fires on the actual harm — rows the next reconcile would delete — not on kind removal as such. Dropping an empty kind is unaffected, so the documented three-deploy removal flow (stop writing → delete the rows → drop from
defineGraph()and migrate) still works exactly as written; Deploy 2 is now what makes Deploy 3 legal instead of being merely advisory. Live rows only, matching theexcludeDeleteddefault of the equivalent probe inStore.evolve().Breaking property changes — the documented reason to reach for
migrateSchema()— are unaffected.MaterializeRemovalsEntrygains a"skipped"variant, carryingreason: "kind-is-live".materializeRemovals()returns it when a queued removal names a kind the active schema declares again, so the decline is reported rather than leaving the queue at a non-zero depth with nothing explaining why. Consumers that switch exhaustively onstatusmust handle it. The type is now a discriminated union, so"failed"carries a requirederrorand"skipped"a requiredreason.Store.evolve()refuses to re-add a kind whose data cleanup is still pending, with aConfigurationErrornaming the kind and pointing atmaterializeRemovals(). Reads filter only by(graph_id, kind), so re-adding before cleanup made the previous incarnation’s rows visible alongside the new ones — and the cleanup was then declined because the kind was live, so they were never reclaimed. The documented cycle (remove →materializeRemovals→ re-add) is unaffected.Two further corrections found while reviewing the above:
- A stale store can no longer resurrect a removed kind. The fold now
strips the supplied graph’s own extension slice before applying the
persisted one, so the committed document is a function of the database
alone. Previously
migrateSchema(backend, store.graph, v)—store.graphis public and returns the merged graph — unioned a stale slice back in and silently undidStore.removeKinds(), leaving a kind the schema called live while itstypegraph_kind_removalsrow stayed queued for a later hard-delete.Store.#catchUpToStoredhas stripped for this exact reason; the schema layer now matches it. discardDroppedKindRows’s documentation was wrong. It claimed the dropped kind’s rows stay and thatmaterializeRemovals“will never clean them up”.materializeRemovalsre-derives removals by walking schema-version history, so the next reconcile hard-deletes them regardless. The flag buys a committed schema, not retained data; the docstring now says so and points callers at copying the rows out first.
- The persisted graph extension (and deprecated-kind set) is now folded in,
exactly as
Patch Changes
Section titled “Patch Changes”-
#328
dc2a386Thanks @pdlug! - Statestore.batch()’s real cost where callers see it.batch()runs its queries in sequence, keeping at most one in flight — at least one statement each, and two for a query whose selective-field mapping falls back after its statement has already executed. So it caps concurrency at best and will not fix an N+1. It is also not a snapshot: PostgreSQL’s default read-committed isolation lets a later query in the batch observe a commit the earlier ones did not, and there is no public way to get one across fluent queries, since a transaction context exposes no query builder.The docstrings for
batch(),BatchableQuery,executeOn, and the edgebatchFind*methods now lead with that, and point at the set-oriented and chunked alternatives, described by what they actually do:.traverse()compiles a chain to one statement,store.subgraph()costs 2 statements on SQLite and 3 on PostgreSQL,getByIds()issues one statement per bind-limit chunk (falling back to concurrent per-id lookups where the backend exposes no batch read), andbulkFindByIndex()costs a probe plus that same chunked hydration.The docs site is corrected to match, including claims that
batch()“minimizes round-trips for reads”, thatbatchFind*collapses N reads into “a single transactional round-trip”, thatsubgraph()is a single statement, and thatgetByIds()is a single query. Transaction support no longer implies a transport shape anywhere: Durable Objects use an ambient transaction with no framing statements, and the non-transactional path may still reuse one client. The changelog entry that shippedbatch()carries a correction note rather than a silent rewrite.Execution semantics are unchanged. One public diagnostic changes: the
ConfigurationErrormessage for a batch endpoint read on a read-onlyStoreViewno longer callsbatch()a “batch loader”. -
#344
ea05d0dThanks @pdlug! - Fence deferred kind cleanup against concurrent schema re-adds. Removal now rechecks the active schema and atomically deletes live rows, recorded-time intervals, vector storage, and contribution markers under the schema lock. Custom backends that implement the optionalschemaWriteTransactioncapability must expose transaction-bound statement execution, table-existence probing, schema DDL, and vector-contribution marker deletion on its callback target. -
#347
1616e93Thanks @pdlug! - Fence schema-version commits against concurrent schema-managed Store writes. SQLite uses its immediate writer transaction; PostgreSQL locks the active schema row in shared mode for managed writes and exclusive mode for schema commits. Managed writes revalidate their Store schema version while holding the fence, so stale queued writes fail instead of landing against a schema that no longer accepts them. Snapshot-isolated PostgreSQL transactions may raise the database’s native serialization failure; callers retry the whole transaction, and graph merge does so automatically. Schema-managed Stores on non-transactional or custom backends without the fence now fail closed on writes. RawcreateStore()instances, direct backend writes, and Stores whose schema metadata was reset byclear()remain outside the versioned guarantee. -
#342
d481054Thanks @pdlug! - Make the documented store query hooks fire for query-builder statements, including prepared queries, batched queries, and selective-projection retries. Each submitted statement now reports its SQL, parameters, row count, duration, and failures through the existingStoreHookscallbacks. -
#343
347d5e3Thanks @pdlug! - Avoid repeated selective-projection fallback queries. Smart-select planning now covers common high-value threshold branches, and prepared queries remember a missing-field fallback so later executions fetch the full row directly.
0.43.0
Section titled “0.43.0”Minor Changes
Section titled “Minor Changes”- #320
010132aThanks @pdlug! - Allow idempotent endpoint-based edge writes to set application-time validity.getOrCreateByEndpointsnow acceptsvalidFromandvalidTo, whilebulkGetOrCreateByEndpointsaccepts them per item. Creation applies both fields, updates and resurrections applyvalidTo, and pure found results leave the existing window unchanged.
0.42.1
Section titled “0.42.1”Patch Changes
Section titled “Patch Changes”- #317
8024711Thanks @pdlug! - Prevent large PGlite bulk writes from silently leaving the connection unable to return rows. PGlite backends now advertise their safe 32,767-parameter limit, PostgreSQL batch sizes follow the active backend capability, and over-budget statements fail before driver dispatch.
0.42.0
Section titled “0.42.0”Minor Changes
Section titled “Minor Changes”-
#313
a797a8bThanks @pdlug! - Expose the schema-commit surface’s decisions as data instead of prose, so callers can pre-flight a proposal and classify a failure without matching message text.MigrationErrornow carries a stabledetails.reasondiscriminant —"schema-behind" | "breaking-change" | "no-active-version" | "version-not-found"(exported asMIGRATION_FAILURE_REASONS) — plus the structureddetails.difffor the outcomes that computed one. Branch ondetails.diff.hasBreakingChangesto tell an additive change from an incompatible one, with no re-query and no substring matching. Note thatMigrationErrorDetails.reasonis now required rather than an optional free-text string.For pre-flight,
classifySchemaChanges(diff)reduces a diff to"identical" | "additive" | "incompatible", and the existing SELECT-onlygetSchemaChangesis now reachable from a store handle:store.schemaChanges()returns the diff andstore.requiresMigration()answers the boolean predicate (alsotruewhen nothing has been committed yet). A least-privilege runtime can detect that it needs the privileged bootstrap instead of discovering the migration wall partway through a request.Documents two operational facts that were previously invisible at the call site: kinds are scoped to the
graph_id(a namespace is a graph id — separate declaration sites do not isolate kinds), and running manygraph_ids with divergent schemas in one database is a supported multi-tenant pattern, including the one cross-graph coupling (SQL index names are database-global, so identical kind+index shapes share a physical index and divergent shapes fail loudly).Also fixes
getSchemaChangesto fold in the persisted graph-extension before diffing, matching what the commit path already does. Without it a compile-time graph was compared against a stored schema that also contains runtime-committed kinds, so those kinds read as removals and an unchanged schema was reported as requiring a breaking migration.
Patch Changes
Section titled “Patch Changes”-
#313
a797a8bThanks @pdlug! - Stop reporting a reordered declaration as a schema change. Restating a kind with its properties, enum members, or edge endpoints listed in a different order is a semantic no-op, but the diff compared those arrays positionally and reported the kind asmodified— forcing callers into a privileged migration for a schema that had not actually changed. A reorderedenumwas even classifiedbreaking, i.e. a pure reordering demanded a destructive-migration decision.required,enum, and edgefromKinds/toKindsare now compared as the sets they are, in both the modified-vs-unmodified decision and the breaking-change severity classification. Genuine changes — added or removed properties, newly required properties, changed enum members, different edge endpoints — are detected exactly as before.The normalization is deliberately scoped to diff comparison and is not applied to the canonical form behind
computeSchemaHash, so no schema hash already committed to a database changes.The normalization walks the document as JSON Schema rather than as plain JSON, because a key’s meaning depends on where it appears. Recursion is an allowlist of known schema-valued keywords; everything else is preserved verbatim:
- Instance data (
default,const,examples) and unknown extension keys — Zod’s.meta()merges arbitrary keys straight into the generated schema — are compared verbatim. Recursing into them would sort a nested key merely namedrequired, silently normalizing away a real change to a stored value. - Keys under
properties,patternProperties,dependentSchemas,$defs, anddefinitionsare user-chosen field names, not keywords, so a field nameddefaultstill has its subschema normalized like any other. dependentRequiredmaps a name to a set of names, so each set is order-normalized.
The allowlist fails in the safe direction: an unrecognized schema-valued keyword is left unsorted, so a reordering inside it reads as a change rather than being hidden.
- Instance data (
0.41.0
Section titled “0.41.0”Minor Changes
Section titled “Minor Changes”-
#311
008fa20Thanks @pdlug! - Make verified adapter stores reusable across connections so serverless/edge deployments that open a fresh database connection per request can verify once per isolate instead of paying a schema-reconcile round-trip on every request.AdapterStorenow exposesreconciledSchema, an opaque snapshot of a store’s reconciled (compile-time + runtime-committed) graph and committed schema version. Pass it to a synchronouscreateAdapterStore(graph, backend, { reconciled })— which issues zero database queries and still validates reads and writes against runtime-committed kinds — or callstore.withBackend(freshBackend)to rebind an already-verified store onto a new connection with no re-verify (the store’s connection is captured immutably, so this returns a new equivalent store rather than mutating in place). The newgetCommittedSchemaVersion(backend, graphId)reads the committed version with a single indexed SELECT, the cheap cross-isolate probe for detecting when another process committed a schema change and the cached snapshot must be refreshed.
0.40.0
Section titled “0.40.0”Minor Changes
Section titled “Minor Changes”- #308
db2dc31Thanks @pdlug! - Replace timestamp-onlyRecordedInstantvalues with versioned anchors that encode a strict per-graph logical revision alongside a non-decreasing physical wall-time high-water mark. Recorded relations store numeric revisions while the public anchor remains one durable string. Upgrade timestamp-only preview tables withmigrateLegacyRecordedTime()and remap external checkpoints withmigrateRecordedAnchor(). Driver timestamps are normalized without host-local timezone parsing, migration integrity failures are typed, and the retained anchor map can be dropped automatically after its final graph is cleaned up. History-enabled async store factories now reject an unmigrated recorded schema at open, including when the legacy tables are empty.
0.39.0
Section titled “0.39.0”Minor Changes
Section titled “Minor Changes”-
#306
cd4e0ebThanks @pdlug! - Add bounded, deterministicscan()pagination to recorded-time node and edge collections so adapters can reconstruct complete historical snapshots without retaining a separate identity inventory. -
#305
4349766Thanks @pdlug! - Harden adapter capability surfaces and document their migrations.This is source-breaking for adapter code that reads
tx.sqlwithout first narrowingtx.sqlAvailability === "available": non-available union arms now omitsqlinstead of exposing it as an optionalnever/undefinedproperty. The runtime history and revision-tracking guards remain fail-loud for JavaScript and type-suppressed callers.Add
openProvenanceStore(targetStore)as the preferred graph-merge provenance API while retainingopenProvenanceStore(backend, targetGraphId)for standalone inspection tools. On Cloudflare D1 and Durable Object SQLite, ignore only a recognizedSQLITE_AUTHrejection of the performance-onlyanalysis_limitPRAGMA and continue with scopedANALYZE; unexpected maintenance failures stay visible.
0.38.0
Section titled “0.38.0”Minor Changes
Section titled “Minor Changes”-
#297
474afe6Thanks @pdlug! - Add global and weighted multi-seed personalized PageRank with induced-subgraph, temporal-view, direction, convergence-tolerance, and working-memory options. -
#303
58855f7Thanks @pdlug! - Move query compilation behind TypeGraph-owned backend and SQL-fragment abstractions so strict consumers no longer typecheck unused Drizzle dialect declarations. Add a Drizzle-freecoreentrypoint and managed full-Store entrypoints for local SQLite and PGlite, with packed TypeScript 5 and 6 regression coverage for both databases.The portable
@nicia-ai/typegraph/indexesentrypoint is also Drizzle-free. Direct Drizzle index-builder helpers moved to@nicia-ai/typegraph/adapters/drizzle/indexes.Advanced adapter APIs now use TypeGraph’s
SqlFragmentinstead of DrizzleSQL: this includes querycompile()results, customGraphBackendimplementations, and custom fulltext/vector strategies. UsetoSQL()for a dialect-rendered{ sql, params }result, orrenderSqlite()/renderPostgres()when rendering a fragment directly.Custom backend and strategy authors can import the complete, Drizzle-free contract vocabulary from
@nicia-ai/typegraph/backend. This entrypoint names every operation parameter, row, strategy payload, dialect port, SQL fragment chunk, and supporting schema/index type referenced by those contracts. API Extractor enforces zero forgotten exports for new entrypoints and fingerprints the complete pre-existing debt set so added or removed leaks cannot pass silently.The default
Store<G>is now the portable TypeGraph surface. It keeps the full graph API and graph-owned transactions while omitting adapter-native handles and caller-owned transaction adoption. Drizzle integration entrypoints returnAdapterStore<G, TNativeTransaction>when precisely typedtx.sql,withTransaction, orwithRecordedTransactioninteroperability is required.createStore,createStoreWithSchema, andcreateVerifiedStorenow return the portable contract; use theircreateAdapterStore,createAdapterStoreWithSchema, andcreateVerifiedAdapterStorecounterparts when the application deliberately needs adapter-native interoperability.Migration map:
0.37 use 0.38 replacement createStoreWithSchema(...)followed bytx.sqlcreateAdapterStoreWithSchema(...)Store<G, TNativeTransaction>AdapterStore<G, TNativeTransaction>TransactionContext<G, TNativeTransaction>AdapterTransactionContext<G, TNativeTransaction>HistoryTransactionContext<G>TransactionContext<G>for portable history stores, orAdapterHistoryTransactionContext<G>for adapter history storesAdoptedTransactionThe adapter’s concrete native-handle type, such as AnySqliteDatabaseorAnyPgTransaction, passed as the adapter genericHistoryTransactionContextandMeasurableHistoryTransactionContextwere removed rather than retained as aliases. Portable stores have one SQL-free transaction context across live and history modes. Adapter contexts exposesqlonly aftersqlAvailability === "available"; the other discriminated union arms omit the property. Store evolution is now generic over the exact Store flavor, so adapter/history/recorded-read surfaces and compatibleStoreRefvalues are preserved without downstream casts. Remove casts that existed only to restore the old widened evolution result.Requiring the
sqlAvailabilitycheck is source-breaking for adapter code that previously readtx.sqlfrom the unnarrowed union. Narrow on the discriminant before passing the handle to even anunknown-typed sink.GraphBackendis now the portable TypeGraph backend port. Native transaction adoption lives onAdapterBackend<TNativeTransaction>, so a capability-less backend cannot be passed to an adapter-store factory. Portable transaction contexts and adapter transaction contexts both expose the same runtime-enforced read-onlyTransactionReadBackend. Adapter contexts add only the precisely typed nativesqlhandle; TypeGraph internals reach the full transaction backend through a non-public, non-enumerable runtime port. Backend functions are now receiver-free (this: void); custom backends must close over their state instead of depending on method receivers.PostgreSQL adapter stores now expose and accept
AnyPgTransactionfor native transaction interoperability. A root Drizzle PostgreSQL database is rejected at compile time and runtime; pass only the transaction handle received by a caller-owneddb.transaction(...). SQLite adoption remains database-handle based so its documented manual-BEGINintegration continues to work.Public
TransactionOptionsnow contains only caller-selectable isolation and access modes. TypeGraph’s temporary-write authorization is an internal, globally branded capability and is no longer expressible through the public transaction contract. Fulltext and vector strategy members are readonly function properties, closing TypeScript’s method-bivariance loophole for third-party implementations. Dialect adapter members use the same receiver-free function-property contract, andTransactionOptionsis exported from the root entrypoint for portable transaction consumers.Managed SQLite and PGlite factories preserve the precise live, history, or recorded-read Store flavor selected by their options, including when options are widened before the call. This keeps unavailable write and native-adapter capabilities unrepresentable instead of relying on runtime failures.
Every Store flavor exposes the safe, Drizzle-free
store.capabilitiesdescriptor for runtime feature checks without exposing backend operations.AdapterHistoryStore.backendexposes the narrowerHistoryStoreBackend, which omits raw SQL, native import, graph clearing, and nested backend transactions so capture-bypassing writes are absent at both type and runtime levels.Backend capability narrowing now uses an exhaustive runtime allowlist instead of default-forwarding proxy overlays. New
GraphBackendmembers must be classified explicitly, preventing adapter capabilities from leaking through a history wrapper. Store evolution also preserves each refined Store flavor and accepts invariantStoreRefvalues for that exact replacement surface.Add checked-in API Extractor reports derived from every package export. CI now fails when the emitted public declaration surface changes without an intentional report update.
Direct SQL fragment values now pass through the same dialect binding normalization as placeholders and compiled queries. Runtime store, transaction, schema, and recorded-read ports use versioned global symbols so mixed ESM/CJS or duplicated bundle instances interoperate safely. Dialect policies outside the compiler are exhaustive records or switches, so adding a new SQL dialect cannot silently inherit SQLite behavior.
Remove the transitional
SQL,SqlRenderDialect, andAdoptedTransactionaliases. ImportSqlFragmentandSqlDialectdirectly. The constructors that brand arbitrary fragments as executable SQL are now internal; public compiled SQL values come from TypeGraph’s query compiler. Managed local stores now live at/sqlite/localand/postgres/pglite; bring-your-own-connection APIs live under/adapters/drizzle/sqlite...and/adapters/drizzle/postgres....The old
@nicia-ai/typegraph/sqliteand@nicia-ai/typegraph/postgresentrypoints were removed. They are not compatibility aliases because those names now distinguish the managed Store API from bring-your-own-connection adapters. Move imports as follows:0.37 entrypoint 0.38 entrypoint /sqlite/adapters/drizzle/sqlite/sqlite/localforcreateLocalSqliteBackend/adapters/drizzle/sqlite/local/sqlite/libsql/adapters/drizzle/sqlite/libsql/postgres/adapters/drizzle/postgres/postgres/pgliteforcreateLocalPgliteBackend/adapters/drizzle/postgres/pgliteThe managed
/sqlite/localand/postgres/pgliteentrypoints keep Drizzle out of their public declarations, but their built-in database implementation still uses Drizzle internally.drizzle-ormtherefore remains a required peer of the 0.38 package; declaration isolation does not imply installation isolation. -
#298
f178663Thanks @pdlug! - Add deterministic synchronous label propagation with exact binary tie-breaking, induced node-kind scope, temporal and recorded-time views, bind-independent neighbor voting, early period-two oscillation detection, and anonMaxIterationscompletion contract:"throw"(default) returns only a converged labeling, while"return"yields the exact fixed-round Graphalytics CDLP labeling.
0.37.1
Section titled “0.37.1”Patch Changes
Section titled “Patch Changes”-
#292
0152c3bThanks @pdlug! - Restore graph algorithms on Cloudflare Durable Objects SQLite. The auto-detecteddo-sqliteprofile now marks temporary-table graph analytics as unsupported, routes shortest-path and reachability algorithms through their inline fallback, and rejects temporary-table-only algorithms with the existing typed capability error instead of leaking workerd’sSQLITE_AUTHfailure. -
#293
9309ec3Thanks @pdlug! - Speed up exact weakly connected components with indexed changed-label frontiers, changed-row-only writes, and one fewer working-table join. Preserve synchronous convergence across bind-limited edge-kind chunks, and align shortest-path identity tie-breaks with portable binary ordering.
0.37.0
Section titled “0.37.0”Minor Changes
Section titled “Minor Changes”-
#269
92479d4Thanks @pdlug! - Vector storage now rides the #135 durable-contribution machinery, so the runtime never issues DDL on the embedding hot path.Previously every vector op (
upsertEmbedding/deleteEmbedding/vectorSearch/createVectorIndex) lazily ranCREATE TABLE IF NOT EXISTSfor its per-(kind, field)table on whatever connection it executed on. On a least-privilege Postgres role (USAGE onpublic, full DML, but noCREATE) this failed withpermission denied for schema public(SQLSTATE 42501) — even when the table already existed, because Postgres runs the schema aclcheck before theIF NOT EXISTSshort-circuit. The fulltext path already avoided this via durable markers; vectors now do too.What changed:
- Boot (privileged):
createStoreWithSchemaprovisions every embedding(kind, field)table + a durable contribution marker, enumerated from the graph.evolve()provisions any embedding fields it introduces. A slot already provisioned at a different shape (the declared dimension changed) is warned about and left untouched — boot stays reachable sostore.reembedVectorField()can recreate it; until then, writes to that field fail with astaleStoreNotInitializedErrorthat points atreembedVectorField. - Runtime writes (DML-only):
upsertEmbedding(single and batch) anddeleteEmbeddingassert the durable marker with a cached, signature-checked SELECT and run DML — never DDL.createVerifiedStoreverifies vector markers at attach, alongside fulltext. - Vector reads are not marker-gated:
store.search.vector,store.search.hybrid, and query-builder.similarTo()predicates compile to SQL against the per-field table directly (searches may override the metric at query time, so their slot legitimately differs from the provisioned shape); against an un-provisioned database they surface the engine’s missing-relation error, whichcreateVerifiedStorecatches at attach. reembedVectorFieldre-stamps the marker after recreating storage at a new dimension; vector-field reclaim (materializeRemovals) clears the marker when it drops a table.
Breaking: vector ops now require a prior privileged
createStoreWithSchema(exactly as fulltext already does). A plaincreateStore+ embedding write with no provisioning step throwsStoreNotInitializedErrorinstead of lazily creating the table.Migration: after upgrading, run
createStoreWithSchema(graph, adminBackend)once under the schema-owner role. It creates the per-field vector tables + markers; least-privilege runtimes then assert markers (SELECT) and run vector DML with zero DDL — noGRANT CREATErequired.Consumers that boot manually (raw DDL + the sync
createStoreattach +backend.ensureRuntimeContributions) provision vectors the same way: the newresolveGraphVectorSlots(graph)export enumerates every embedding(kind, field)slot, andbackend.ensureVectorSlotContribution(slot)materializes each — the exact stepcreateStoreWithSchemaperforms. Batch counterparts (backend.ensureVectorSlotContributions(slots)/backend.assertVectorSlotsInitialized(slots)) resolve every slot’s markers with one graph-scoped query — what boot and verified attach use, and the right choice for many embedding fields over a remote connection. - Boot (privileged):
-
#284
26f5b4aThanks @pdlug! - TypeGraph’s base-relation indexes are now system-index declarations — a single declared list (SYSTEM_INDEX_DECLARATIONS) that both dialect schemas derive from and that materializes onto already-initialized databases.Previously the base indexes were hand-written twice (once per dialect schema) and applied only by first-boot bootstrap DDL, so an index added in a newer library version never reached an existing database without manual DDL (the gap #282 exposed). Now:
- Single source, parity by construction.
createSqliteTables/createPostgresTablesbuild their node/edge/recorded-relation indexes from the same declarations, and a cross-dialect extraction test asserts the two generated DDL scripts’ full index sets stay identical. - Upgrade path.
createStoreWithSchemabrings a database’s system indexes up to the running library version at boot —CREATE INDEX CONCURRENTLYon PostgreSQL, riding the same status table, drift signatures, invalid-leftover healing, and cross-caller claim protocol as graph-declared indexes. A database whose indexes all exist settles from three concurrent catalog/status reads (scoped to the sessionsearch_path, so schema-per-tenant databases never observe each other’s indexes) with no index DDL and no status writes — the only DDL on that warm path is the idempotent status-tableCREATE TABLE IF NOT EXISTSensure step every materialize verb runs. A system index that is physically absent or invalid is rebuilt even when a stale success row survives (dump/restore, manual drop). Failures — including status-table infrastructure errors — degrade to a warning: indexes are a performance concern and the store still boots. Deployments that must not run index builds inline at boot passsystemIndexes: "skip"tocreateStoreWithSchemaand materialize out-of-band. - New API:
store.materializeSystemIndexes()for deployments that boot withoutcreateStoreWithSchema(zero-DDL attach) — call once under a DDL-capable role after upgrading. Strict where the boot path is lenient: throwsConfigurationErroron backends without DDL/status primitives. IndexEntitygains a"system"member; system status rows carry the relation key (e.g."recordedNodes") in theirkindcolumn.
Generated DDL is unchanged for default and short custom table names — same index names, columns, and order — so existing databases and drizzle-kit migrations are unaffected. Names that would exceed PostgreSQL’s 63-char identifier bound (very long custom table names) are now deterministically truncated + hash-suffixed instead of being silently truncated by the engine into collisions. System index names are reserved: a graph-declared index using one is rejected at table definition and by
materializeIndexes()(previously itsCREATE INDEX IF NOT EXISTSsilently no-opped against the differently-shaped system index while recording success). Legacy databases that predate the recorded relations skip those indexes cleanly instead of attempting failing DDL at every boot. - Single source, parity by construction.
-
#273
42f6941Thanks @pdlug! - AddtrustedImportGraphandtrustedImportGraphStreamfor atomic initial loads into a fresh, dedicated database. The distinct trusted surface bypasses schema, reference, cardinality, and conflict validation; uses prepared SQLite writes or PostgreSQLUNNESTingestion; defers rebuildable secondary indexes; refreshes planner statistics; and rolls the complete stream back on any failure.The first version rejects non-empty TypeGraph data tables, recorded history, revision tracking, uniqueness constraints, searchable fields, vector fields, and backends without the required native transactional path.
-
#279
c44eeacThanks @pdlug! - Add exactstore.algorithms.weaklyConnectedComponents()for transactional SQLite and PostgreSQL backends. Results include deterministic component representatives and sizes, honor valid/recorded temporal views, and fail with a typed convergence error instead of returning partial labels when the configured iteration budget is exhausted. Callers can restrict WCC to anodeKindsinduced subgraph, retaining isolated in-scope nodes without seeding unrelated node kinds.PostgreSQL iterative operations now refresh temporary-table planner statistics after sufficiently large seeds and multiplicative growth, avoiding plans based on the engine’s initial one-row estimate. The policy also covers growing BFS working tables and is a no-op on SQLite.
Set-based reachability now deduplicates edge targets before target-node visibility checks and avoids computing unused predecessor paths. This reduces dense-frontier work while preserving minimum-depth results and cross-backend semantics.
-
#288
17a3f83Thanks @pdlug! - Addstore.algorithms.weightedShortestPath— a minimum-total-weight path search weighting each traversed edge by a numeric edge property (LDBC Interactive IC14 shape). Runs frontier-based relaxation on the shared iterative substrate with best-target pruning, works on both execution paths (temporary working table and inline fallback), and honors valid-time and recorded-time coordinates including pinned StoreViews. Edge weights are audited up front: negative, non-numeric, out-of-range, or (withoutdefaultWeight) missing weights throw the new typedInvalidEdgeWeightError. Weight arithmetic is IEEE 754 double precision on both backends, so total weights are backend-identical; among equal-total-weight paths the returned node sequence is too, except when theedgeslist exceeds the backend’s bind-parameter budget (hundreds of edge kinds in one call).
Patch Changes
Section titled “Patch Changes”-
#282
923219dThanks @pdlug! - Add a(graph_id, id)index to the live and recorded node tables so bare-id lookups (a node’sidwithout itskind) seek instead of scanning the graph’s node partition — the composite keys lead withkind, so they can’t serve that probe.store.algorithms.degree()’s node-kind subquery is the main consumer: ~95 ms → sub-millisecond at LDBC SNB SF1 (3.16M nodes) on SQLite, at the live and recorded coordinates alike.New databases get both indexes at bootstrap. Existing databases adopt them with a one-time
await backend.bootstrapTables()— every statement isCREATE … IF NOT EXISTS, so the call is idempotent and only creates what’s missing. On PostgreSQL this issues a plainCREATE INDEX(briefly locks writes on large tables); schedule it, or apply the equivalentCREATE INDEX CONCURRENTLYstatements manually. -
#265
35ab2a0Thanks @pdlug! - Docs: scope thecoalesceUnchangedUpsertsbenefit correctly. Coalescing eliminates re-delivery churn (an already-applied change delivered again, value-identical to the live row). It does not make a full replay-from-zero free when the stream supersedes values in place: re-applying an older value over the live row is a genuine change, and restoring the current value afterwards is another, so such a replay still writes — and leaves a spurious back-and-forth band in the live store’s recorded history. Churn-free rebuilds replay into a fresh store instead. Clarified in the option’s TSDoc and in the “Materializing external event logs” guide; no behavior change. -
#289
199b33aThanks @pdlug! - Cap SQLite-backed Durable Object statements at Cloudflare’s 100-bound-parameter limit. Structural client detection now makes platform identity authoritative over stale execution hints, and capability overrides cannot raise the hard ceiling. Recorded-history capture and every capability-driven SQLite batch path chunk large writes before workerd rejects the query, while SQLite literal list predicates use one JSON-bound parameter instead of one bind per element. -
#285
9949562Thanks @pdlug! - Cut three overheads out of the iterative graph algorithms, root-caused withEXPLAIN (ANALYZE, BUFFERS)against LDBC SNB SF1 on PostgreSQL.Weakly connected components no longer re-validates node visibility per edge in its propagate rounds. The working table is seeded through the same graph/kind/temporal filters inside the same snapshot and both edge endpoints are already joined against it, so membership is the visibility proof; the per-edge
typegraph_nodesindex loops (hundreds of thousands per round on SF1) added nothing. Results are byte-identical.Traversal rounds now carry their own bookkeeping instead of issuing follow-up statements: seeding returns the frontier through
INSERT … RETURNING, and bidirectional shortest-path rounds detect the frontier meeting inside the expansion statement rather than with a separate probe per round. A shortest-path traversal that used to issue two to three statements per round now issues one, roughly halving round-trip latency on latency-bound connections. The working-tableANALYZEpolicy is unchanged in its thresholds but no longer runs when no further round will read the table. When several equal-depth meetings exist, the tie now breaks by node id then kind in code-unit order on both backends — previously the selection followed the database collation, so a PostgreSQL cluster with a linguistic default collation could pick a different (equally shortest) path.New option: iterative algorithm calls (
reachable,shortestPath,canReach,neighbors,weaklyConnectedComponents) acceptworkingMemory?: string, an opt-in, transaction-scoped override of the session’swork_mem, applied on PostgreSQL withSET LOCALsemantics via parameterizedset_config. By default (option omitted) operations inherit the server’s configuredwork_mem— nothing is overridden.work_memis a threshold each sort/hash operator (and each parallel worker) may allocate up to, not a per-operation budget, and concurrent calls multiply it; set it deliberately (e.g."64MB") for large single-tenant analytical runs where the configured default spills whole-graph sorts to disk (measured ~106MB external merges per WCC round on SF1). The override never touches the session or server setting, is validated as<digits>kB|MB|GBwithin PostgreSQL’s acceptedwork_memrange (64kB–2147483647kB) with the same typed error on both backends, and is ignored by SQLite. -
#290
247c1b7Thanks @pdlug! - Fix PostgreSQL pointer-levelpathIsNull()/pathIsNotNull()predicates misclassifying two stored value shapes. The previous text-comparison form (#>> path = 'null') went three-valued on a stored JSONnull— sopathIsNull()silently failed to match those rows on PostgreSQL while matching them on SQLite — and misread the JSON string"null"as null, falsely matching it withpathIsNull()and excluding it frompathIsNotNull(). Both predicates are now type-based (jsonb_typeof) and never SQL NULL, converging on SQLite’s (correct) semantics. Field-levelisNull()/isNotNull()predicates were already correct and are unchanged. Behavior change on PostgreSQL for affected data: rows holding a JSONnullnow matchpathIsNull(), and rows holding the string"null"no longer do. -
#283
8306680Thanks @pdlug! - SelectiveORDER BY … LIMITqueries now compile with late materialization: the query sorts and limits a lean candidate set carrying only identity, sort keys, and predicate columns, then re-fetches the deferred projection columns by primary key for only the surviving rows — instead of extracting every projected column for every candidate and discarding all but theLIMITsurvivors after the sort. At LDBC SNB SF1, IC9’s top-20 over a 1.18M-comment fan-out stops extractingcontent1.18M times, ~30–37% faster on SQLite.The transform fires only on the selective
.select()path withORDER BYand a positiveLIMITat the live coordinate. Aggregates, vector/fulltext, optional (LEFT JOIN) traversals, edge-field projections, non-selective queries, and recorded-time reads keep the flat plan unchanged. -
#274
2a889aaThanks @pdlug! - Replace path-enumerating recursive CTEs inreachable,neighbors,shortestPath, andcanReachwith set-based breadth-first search.Transactional SQLite and PostgreSQL backends now execute graph iterations against a connection-local temporary working table, de-duplicated by node kind and ID on every round. Non-transactional backends retain parity through a bind-limit-aware inline frontier. Traversals run in one snapshot where the backend supports transactions, preserve temporal filtering, and clean up temporary state on success or failure.
0.36.0
Section titled “0.36.0”Minor Changes
Section titled “Minor Changes”-
#261
5bc7b53Thanks @pdlug! - Return a receipt fromstore.withRecordedTransaction, and add scoped write measurement withtx.measure.-
store.withRecordedTransaction(externalTx, fn)now returnsPromise<TransactionOutcome<T>>instead ofPromise<T>. The adopted path is the only way to get exactly-once cursors and graph writes atomically on a history store, and it now surfaces the same receipttransactionWithReceiptdoes:receipt.writesfor dropped-change detection andreceipt.recordedas the per-transaction replay anchor (undefinedfor a read-only callback or a non-history store).BREAKING: the adopted path now returns the result under
.result. Migrate by destructuring:// Beforeconst x = await store.withRecordedTransaction(externalTx, fn);// Afterconst { result: x } = await store.withRecordedTransaction(externalTx, fn); -
Scoped receipts —
tx.measure((scoped) => ...). On the receipt-enabled contexts (transactionWithReceipt,withRecordedTransaction),tx.measureruns its callback with a scoped context — a second view over the same transaction — and returns aTransactionOutcomewhose receipt counts exactly the writes made through that scoped context (scoped.nodes/scoped.edges). So a framework can attribute writes to user code it invoked (e.g. a materializer measuringproject(scoped, change)to detect a dropped change) while its own bookkeeping — written through the outertx— stays out of the count. Attribution is by which context you write through, not by timing, which makes overlapping and concurrent measures safe by construction (two scopes racing underPromise.allnever cross-count). Nesting composes; measured writes still count in the outer receipt; a scoped receipt’srecordedis alwaysundefined. Plainstore.transaction()contexts have nomeasure(that path runs no recorder and stays zero-overhead). New exported types:MeasurableTransactionContext,MeasurableHistoryTransactionContext,ScopedMeasure<Ctx>. -
Adopted contexts seal on return. A transaction context retained and written through after its
withRecordedTransactioncallback resolves now fails loud on both paths — the history path’s capture guard is checked before the live write (so a swallowed error can no longer commit an uncaptured row), and the non-history path seals its receipt-tracked collections (so a post-return write can’t persist a row the already-returned receipt never counted).
-
-
#262
34468a0Thanks @pdlug! - Add an opt-incoalesceUnchangedUpsertsstore option for at-least-once / replay materializers.Idempotent event-log projectors converge live state correctly, but every re-delivery of a byte-identical value still performed a real write:
upsertByIdon an existing id calledupdateNodeunconditionally, allocating a fresh recorded instant and a new history row. A full replay of an N-event log therefore rewrote every row and grew recorded history by N — the recovery / rebuild workload inflates history the most.With
createStore(graph, backend, { coalesceUnchangedUpserts: true }), anupsertById(orbulkUpsertByIditem) whose validated props are value-identical to the existing live row performs no write at all: noupdateNode, no recorded-time capture, no history row, no revision-anchor advance, and noupdateoperation hooks. It resolves with the existing node. The dirty-check compares the storage-normalized representation (props run through the kind’s Zod schema, key-order-independent), so it answers exactly “would the persisted value differ?”.A write still happens (never coalesced) when the row is soft-deleted (an upsert resurrects it), when an explicit
validFrom/validTois passed, or when any prop differs. Default off, because some consumers want an audit row per re-delivery. Covered symmetrically for edgebulkUpsertById(props only — endpoints are the edge’s identity).Receipt semantics are unchanged and need no new signal: a coalesced upsert still counts as one write intent (
writes.total) but captures nothing (recordedstaysundefined) — the same two-signal shape as a no-op delete, which at-least-once consumers already handle by carrying the prior anchor forward. -
#260
35d03aeThanks @pdlug! - Make the store transaction surface tell the truth about raw SQL and history capture.-
New
tx.sqlAvailabilitydiscriminant. Every transaction context now carries a requiredsqlAvailability: "available" | "history" | "revisionTracking" | "unavailable"field. Branch on it instead of truthiness-testingtx.sql: underhistory: true/revisionTracking: truethe raw handle is present-but-throwing (soif (tx.sql)read truthy and then threw), and it isundefinedonly on the non-transactional fallback."available"meanstx.sqlis a usable raw handle;"history"/"revisionTracking"mean raw SQL is disabled here;"unavailable"means the backend has no transactions (tx.sql === undefined, no atomicity). -
store.withTransaction()on a history-enabled store is now a compile error. It always threw at runtime; the call site now rejects the argument with a message pointing atstore.withRecordedTransaction(). The runtime guard is unchanged for suppressed calls. -
Branchable recorded-capture guard codes. The
ConfigurationErrors these guards throw carry a stabledetails.code(RECORDED_CAPTURE_REQUIRES_CALLBACK_TRANSACTION,RECORDED_CAPTURE_RAW_SQL_DISABLED,REVISION_TRACKING_RAW_SQL_DISABLED), now exported asRECORDED_CAPTURE_GUARD_CODESwith aRecordedCaptureGuardCodetype and anisRecordedCaptureGuardError(error, code?)type guard — so a portable caller can distinguish “history forbids raw SQL here” from “this backend has no transactions” without substring-matching the message. -
Fixed
withRecordedTransaction’s JSDoc, which incorrectly promisedtx.sql; on the adopted path you already hold the pinned connection, so write your own relational tables through the external transaction handle you passed in.
-
0.35.0
Section titled “0.35.0”Minor Changes
Section titled “Minor Changes”-
#231
839f536Thanks @pdlug! - Aggregate queries now support.orderBy(). PreviouslyExecutableAggregateQueryexposedlimit()but no way to order results, so.aggregate({...}).limit(n)returned an arbitraryngroups rather than the topn— the most common aggregate shape (“top N groups by count/sum”) required fetching every group and sorting in JS..orderBy(key, direction?)takes any output name from.aggregate({...})— either a grouped field or an aggregate alias — and can be chained for multi-key sorts:store.query().from("Author", "a").traverse("wrote", "e").to("Book", "b").groupByNode("a").aggregate({ author: field("a", "name"), bookCount: count("b") }).orderBy("bookCount", "desc").limit(2).execute();Ordering resolves against the projected SELECT-list output alias rather than recompiling the underlying expression, so it works uniformly for grouped fields and aggregates on both SQLite and PostgreSQL with no dialect-specific handling.
-
#212
dcdd542Thanks @pdlug! - AutocommitbulkCreateandbulkInsertcalls (nodes and edges) now refresh planner statistics automatically when a single call writes 1,000 rows or more, closing the stale-statistics window after bulk loads where the planner keeps pre-load row estimates until ANALYZE runs (observed 25-200x slowdowns on traversal and fulltext shapes). Tune the threshold or disable with the newautoRefreshStatisticsstore option (createStore(graph, backend, { autoRefreshStatistics: 5000 })orfalse). Bulk writes inside a caller-provided transaction never auto-refresh — statistics cannot see uncommitted rows — and a refresh failure degrades to a warning without failing the committed write.importGraph()keeps its existing built-in refresh. -
#195
e48dfa2Thanks @pdlug! -bulkCreatenow batches its round trips end to end instead of degenerating into per-row statements around one multi-row INSERT.- Validation probes: per-row existence checks collapse into one
getNodesper kind, and per-row uniqueness pre-checks into onecheckUniqueBatchper (constraint, kind) — the batch validation caches are primed up front, so the per-row checks run against memory. Validation now runs as a synchronous first pass, so a later row’s validation error can surface before an earlier row’s constraint error (both fail the whole batch). - Side effects: uniqueness entries write through a new
insertUniqueBatch(multi-row conditional upsert with the same per-entryUniquenessErrorsemantics), fulltext sync goes through the existingupsertFulltextBatch, and embedding sync through a newupsertEmbeddingBatchper (kind, field) — implemented for pgvector, sqlite-vec, and libSQL native vectors via an optionalVectorStrategy.buildUpsertBatchseam with a per-row fallback for custom strategies.
Measured on the write bench (in-memory SQLite, 100-row batches of nodes with searchable + embedding fields): ~1,600 → ~4,100 rows/s (~2.6×). The win compounds on per-statement-networked engines (Turso, D1, Neon), where each eliminated statement is a network round trip.
- Validation probes: per-row existence checks collapse into one
-
#194
b3668c9Thanks @pdlug! - Default-path performance tuning for SQLite and bulk maintenance verbs.createLocalSqliteBackendnow applies connection pragmas at open:journal_mode=WAL,synchronous=NORMAL, and a 5sbusy_timeout. On file-backed databases this makes single-operation writes roughly 5× faster than the better-sqlite3 driver defaults (rollback journal,synchronous=FULL), because each write no longer pays a full-durability fsync in journal mode. Override individual values via the newpragmasoption, or passpragmas: falseto keep driver defaults.- The SQLite backend now detects the connection’s real bound-parameter
budget instead of assuming the historic 999: better-sqlite3 compiles in
SQLITE_MAX_VARIABLE_NUMBER=32766(probed viaPRAGMA compile_options, with asqlite_version() >= 3.32fallback), Cloudflare D1 is capped at its documented 100, and undetectable async drivers keep the conservative 999 floor. Batch chunk math derives from the detected budget, so bulk inserts on better-sqlite3 use ~33× fewer statements (111-row chunks → 3,640-row chunks), and batched writes on D1 no longer exceed its per-statement limit.capabilities.maxBindParametersreports the detected value and remains overridable. importGraph()now refreshes planner statistics (ANALYZE) automatically after an import that created or updated rows, andstore.materializeIndexes()does the same on SQLite after creating indexes. Stale statistics after bulk loads previously degraded traversals ~10× on PostgreSQL and some FTS5 queries ~30× on SQLite until the engine caught up on its own. Both verbs acceptrefreshStatistics: falseto opt out. On PostgreSQL,materializeIndexes()builds withCREATE INDEX CONCURRENTLYand skips the automatic refresh (concurrent same-index builds from two callers can deadlock when a refresh shifts their timing) — callstore.refreshStatistics()after materializing.- PostgreSQL
refreshStatistics()now issues oneANALYZE (SKIP_LOCKED)per table instead of a single multi-tableANALYZE. A multi-table ANALYZE is one transaction acquiring several ShareUpdateExclusive locks in sequence, and ANALYZE’s lock class conflicts with in-flightCREATE INDEX CONCURRENTLYbuilds — the old shape could deadlock against concurrent index DDL; the new one can never join a lock-wait cycle (a locked table is skipped and covered by the next refresh or autovacuum).
-
#247
191e877Thanks @pdlug! - Declare, as a typed capability, whether a backend’s filtered approximate vector search can silently return a short page.Every approximate (ANN) search TypeGraph issues carries at least one row filter — the liveness predicate that hides soft-deleted and out-of-validity rows — and a
.where(...)predicate narrows it further. Where the engine applies that filter relative to the index traversal decides whether the page fills:sqlite-vecpushes the filter into thevec0KNN candidate set. Exact — the only engine here that guarantees a full page.pgvector≥ 0.8 re-enters the index for more candidates (hnsw.iterative_scan/ivfflat.iterative_scan, applied automatically). Much better recall than a post-filter, but not a guarantee: the iterative scan stops athnsw.max_scan_tuples/ivfflat.max_probes, and on pgvector < 0.8 there is no iterative scan at all — the backend detects that at runtime, warns once, and the search staysef_search-bounded.libsql-nativecannot do either: DiskANN’svector_top_kis a table function with no filter pushdown. TypeGraph over-fetches4 × (limit + offset)neighbors and post-filters, so once more than that headroom is filtered out the search returns fewer thanlimitrows even though more matches exist. Heavy tombstone drift — routine in a temporal store — is what makes this real rather than theoretical.
That asymmetry was previously only a code comment.
VectorCapabilitiesnow carries a requiredfilteredApproximateSearch: { mode, guaranteesFullPage }. ReadguaranteesFullPage, notmode—mode("filter-pushdown" | "iterative-scan" | "post-filter") names the mechanism the strategy asks for, but onlyguaranteesFullPagereflects the runtime-dependent, scan-bounded reality (it istrueforsqlite-vecalone). It is documented in the backend parity matrix, and boundary tests execute the difference against real libSQL, sqlite-vec, and pgvector: the same 200-vector fixture, the same filter, the samelimit.Breaking for custom vector strategies only.
VectorCapabilitiesgained a required field, so a hand-writtenVectorStrategymust now declare both its mode and whether it guarantees a full page. That is deliberate: an omitted declaration would inherit an engine promise the strategy may not keep. -
#198
a9477bbThanks @pdlug! - Property filters that a btree can never serve now have a declarative index story:defineNodeIndex/defineEdgeIndexacceptmethod: "gin" | "trigram"(default"btree", unchanged).method: "gin"emits a PostgreSQL expression GIN (jsonb_path_ops) over the field’s jsonb extraction, serving the array containment predicates (contains/containsAll/containsAnyon array fields). Verified to match TypeGraph’s compiled(props #> ARRAY[…]) @> $1form under parameterized prepared statements — note that a hand-written whole-columnGIN (props)never matches these expressions (the previous docs guidance recommended one; corrected).method: "trigram"emits an expression GIN withgin_trgm_opsover the field’s text extraction, serving substring and case-insensitive matches (contains/startsWith/endsWith/like/ilikeon string fields).materializeIndexes()installspg_trgm(CREATE EXTENSION IF NOT EXISTS) on first use.
Both are materialize-only (like vector ANN indexes) and PostgreSQL-only:
materializeIndexes()reports them asskippedon SQLite, whose substring-search story is FTS5 fulltext. GIN-family declarations take exactly one field and rejectunique,coveringFields, andwhere;method: "btree"is canonicalized by absence so existing stored schema documents and materialization signatures are unchanged.bulkFindByIndexrejects GIN-family indexes (it compiles equality probes, which only btree declarations serve). -
#204
94eea90Thanks @pdlug! - perf:store.search.hybridnow runs as a single SQL statement on the built-in backends — both sources, weighted RRF fusion, liveness, and node hydration composed into one round trip (previously two search statements plus an id-hydration fetch, with fusion in JS). Results are identical to the previous path; the saving scales with per-statement cost (serverless drivers, D1/Durable Objects, remote databases).GraphBackendgains an optionalhybridSearchmember; backends without it (custom backends, capability profiles without window functions) keep the multi-statement fallback. -
#223
a161d70Thanks @pdlug! - AddasNodeIdandasEdgeIdconstructors for branding persisted ids that round-trip through untyped storage before being passed back to read, update, or delete APIs. -
#241
8f3e772Thanks @pdlug! - Fixesimplies(edgeA, edgeB)silently accepting endpoint-incompatible edge pairs. Previously an ontology declaration likeimplies(about, writes)— whereaboutconnectsPaper -> TopicandwritesconnectsAuthor -> Paper— was accepted without complaint, andexpand: "implying"query traversal would then silently foldaboutrows into awritestraversal even though the two edges connect entirely different node kinds.implies()relations are now validated wherever a query-capableKindRegistryis built —createStore()/createStoreWithSchema()for a live graph definition, anddeserializeSchema(...).buildRegistry()for a persisted schema — including relations authored throughstore.evolve({ ontology }). A relation is accepted when every kind the implying edge allows on a side (from/to) is assignable — equal, or asubClassOfdescendant — to at least one kind the implied edge allows on that same side; otherwise construction throws aConfigurationErrordescribing the incompatible kinds and how to fix the declaration.Breaking change — two things to know before upgrading.
It breaks the load path, not just graph definition.
deserializeSchema(...)runs the same endpoint check insidebuildRegistry(), so a schema already persisted under 0.34 that carries a now-rejectedimplies()relation throws at the firstbuildRegistry()after the upgrade — no code change of yours required to trigger it. Audit persisted schemas before rolling out, not only the graph definitions in source.It rejects superset domains, not only disjoint ones. A relation is accepted only when every kind the implying edge allows on a side is assignable to at least one kind the implied edge allows on that side. So
implies(a, b)whereais declaredfrom: [Person]andbis declaredfrom: [Employee](withEmployee subClassOf Person) is rejected, even though everyarow on disk might in fact start at anEmployee:Personis not assignable toEmployee. The declaration, not the data, is what the traversal folds on, and aPerson-rootedarow folded into abtraversal would be unsound. The same rule is what makes the previously-silent disjoint case (Paper -> TopicimplyingAuthor -> Paper) an error.Fix such relations by narrowing the implying edge’s endpoints, adding a
subClassOfrelation to bridge the mismatch, or removing theimplies()declaration. -
#195
e48dfa2Thanks @pdlug! -importGraphnow processes eachbatchSizeslice with batched round trips instead of fully single-row statements. Nodes: onegetNodesper kind for existence, onecheckUniqueBatchper (constraint, kind) for uniqueness pre-checks, one multi-row insert, and one batched side-effect pass (uniqueness entries, fulltext, embeddings) for the accepted creates. Edges: onegetNodesper endpoint kind for reference liveness, onegetEdgesfor existence, and one multi-row insert.Per-row semantics are unchanged: conflicts route by
onConflict, a uniqueness conflict is recorded as a per-row error entry (the rest of the import proceeds), reference validation still rejects missing or tombstoned endpoints, and rows repeating an id within a slice fall back to the per-row path so they observe the first occurrence’s row exactly as before.Measured on the write bench (in-memory SQLite, 500 nodes + 500 edges per import): ~26k → ~96k entities/s (~4×). The win compounds on per-statement-networked engines (Turso, D1, Neon), where the old path paid one round trip per row and the new one pays a handful per slice.
-
#236
31aee82Thanks @pdlug! -defineNodeIndexaccepts a newkeySystemColumnsoption: system columns (e.g."id") to include in the index key, positioned after thescopeprefix and beforefields/coveringFields.fieldsis now optional (was a required non-empty tuple) — an index must declare at least one offields,coveringFields, orkeySystemColumns.This closes a real gap: a covering index can only serve a query’s join index-only (avoiding a heap fetch per candidate row) if the index’s key matches the join’s actual predicate. Queries that join on a system column directly (e.g. TypeGraph’s compiled
n.id = e.from_idfor a reverse traversal) had no way to declare a matching index, sincefields/coveringFieldsonly ever accept the node’s own schema properties.keySystemColumns: ["id"](pluscoveringFieldsfor whatever the query also projects) now lets that same join be served index-only.Rejects edge-only system columns (
from_kind/from_id/to_kind/to_id) on a node index, and rejects any column already implied byscope. Not supported withmethod: "gin" | "trigram"(same restriction ascoveringFields). Also rejectsunique: truecombined withkeySystemColumns: ["id"]— every node’sidis already unique per row, so a unique index keyed onidplus other columns can never enforce a meaningful constraint across those other columns. Canonicalized by absence, likemethod: indexes that don’t use it produce byte-identical names/hashes to before this field existed, so existing stored schema documents and materialization signatures are unaffected. -
#208
586b2b0Thanks @pdlug! - fix:materializeIndexesserializes same-index builds across callers on PostgreSQL via a durable claim in the status table (two concurrent same-name expression-indexCREATE INDEX CONCURRENTLYbuilds can deadlock — no safe-snapshot exemption). Losers wait and converge asalreadyMaterialized; a crashed builder’s claim expires after a 15-minute lease and the takeover drops the INVALID index leftover before rebuilding (relational indexes now self-heal instead of requiring manual repair). With same-index builds serialized, the automatic post-createANALYZEis re-enabled on PostgreSQL. -
#201
b52ae3bThanks @pdlug! - perf: eliminate the PostgreSQL JSONB parse→stringify→parse round trip per row.Public backend row contract change: rows returned by
GraphBackendread methods now carrypropsasRowProps = string | Readonly<Record<string, unknown>>— JSON text on SQLite, the driver-parsed object on PostgreSQL. Code that consumed backend rows directly withJSON.parse(row.props)must switch to the newrowPropsToObject(row.props)(orrowPropsToJsonTextwhen text is required); both helpers and theRowPropstype are exported from the package root. Store-level APIs (store.nodes.*,store.query(), search, export) are unaffected — they already return parsed objects. -
#249
d2a6febThanks @pdlug! - Add revision-anchored graph branches and streaming interchange. Stores can opt intorevisionTracking: true(or usehistory: true) so branch and merge validation read a durable per-graph origin and revision instead of fingerprinting every live row or accepting a coincident revision from another store. Physical branch clones now stream bounded interchange batches, enabling large branch copies, exports, and imports without materializing the full graph in memory. Direct backend writes remain outside the revision-tracking contract; tracked stores fail loudly iftx.sqlwould bypass that contract. -
#203
801768dThanks @pdlug! - feat: facade search scoping —store.search.{vector,fulltext,hybrid}acceptwhere(a property predicate compiled by the shared query compiler into the search statement’s candidate set),offset(rank-relative pagination pushed into the engine), andincludeSubClasses(searchsubClassOfdescendants and merge into one ranking). Filters compile into the search statement’s candidate set — exact on pgvector, sqlite-vec, tsvector, and FTS5, where a filtered search returnslimithits whenever enough matches exist; libSQL DiskANN post-filters a 4× over-fetched ANN set, so its recall against the filter is bounded by that headroom. Search now applies full current-read semantics (validity windows, not just tombstones), matchingfind(). -
#205
17bbe54Thanks @pdlug! - feat:.similarTo(vector, k, { approximate: true })— opt-in approximate retrieval for the inline vector predicate. Each declaring kind’s relevance branch compiles to the engine’s native ANN search form (vec0MATCH … k=, libSQLvector_top_k, pgvector’s index-eligible scan), scoped to the query’s candidate nodes via the same pushdown the search facade uses, so composed predicates and traversals still constrain results. Never applied silently: the default remains the exact distance scan, and slots declaredindexType: "none"keep it even with the opt-in. -
#245
ef6def6Thanks @pdlug! -createLocalSqliteBackend’spragmasoption accepts two new fields:cacheSizeKib(PRAGMA cache_size) andmmapSizeBytes(PRAGMA mmap_size). Both default toundefined, leaving SQLite’s own built-in defaults (a 2MiB page cache, mmap disabled) untouched — existing callers are unaffected.SQLite’s 2MiB default cache is fine for a small embedded database, but once a database’s working set exceeds it, every page a query touches past that point pays a fresh disk read instead of a cache hit — including pages an otherwise fully covering index would have served from cache alone. Set
cacheSizeKib(and optionallymmapSizeBytes) once a database’s working set is known to exceed the default, the same way you’d size a page cache for any other embedded or server database engine. -
#197
f420a92Thanks @pdlug! - SQLite CRUD statements now reuse the prepared-statement cache. The operation backend’s read/write helpers previously executed through drizzle’sdb.all()/db.run(), which re-prepares every statement on every call — only the query engine’sbackend.executepath used the prepared-statement LRU. On synchronous drivers (better-sqlite3, bun:sqlite) CRUD statements and the per-write transaction frames (BEGIN IMMEDIATE/COMMIT/ROLLBACK) now route through the execution adapter’s compiled path, so a repeated operation shape re-binds parameters against a cached prepared statement. A warmed CRUD cycle re-prepares nothing. Async drivers (remote libsql/Turso, D1) have no statement cache and keep the existing execution path.Measured on the write bench (in-memory SQLite, order-controlled A/B): single-op creates ~18.3k → ~28.8k ops/s (~1.6×), transaction-batched creates ~23.9k → ~36k ops/s (~1.5×).
-
#251
f23f7a5Thanks @pdlug! -createLocalSqliteBackend’spragmasoption accepts a new field:walAutocheckpointPages(PRAGMA wal_autocheckpoint). Defaults toundefined, leaving SQLite’s own built-in default (1,000 pages, ~4MiB) untouched — existing callers are unaffected.SQLite’s default checkpoints WAL back into the main database file every ~4MiB. That’s fine for a normal read/write mix, but a large bulk load pays increasingly expensive checkpoints as the database file grows over the course of the load — each checkpoint has to flush WAL frames into a B-tree that’s larger, and less page-cache-resident, than the one before it. A local repro (real
bulkInsert()calls, 100K/500K/2M synthetic rows) confirmed this: raisingwalAutocheckpointPagescut a 2M-row bulk load’s wall-clock time by over 50% at the largest scale tested, with the effect growing at larger row counts. SetwalAutocheckpointPagesfor a bulk-insert-heavy workload;0disables automatic checkpointing entirely for callers that would rather run one explicitPRAGMA wal_checkpointafter the load finishes. -
#222
7588634Thanks @pdlug! - Addstore.transactionWithReceipt(), which runs a transaction and returns a receipt summarizing completed collection write intents and, for history-enabled stores, the recorded commit instant allocated by the transaction. -
#233
e0e6304Thanks @pdlug! - EveryTypeGraphErrorsubclass with a fixed-shapedetailspayload now declares a narrowedreadonly detailstype (e.g.RestrictedDeleteError.detailsisRestrictedDeleteErrorDetails, not the base class’sReadonly<Record<string, unknown>>), so reading structured fields likeerror.details.edgeCountno longer requires a cast. The newXxxErrorDetailstypes (NodeNotFoundErrorDetails,EdgeNotFoundErrorDetails,KindNotFoundErrorDetails,NodeConstraintNotFoundErrorDetails,NodeIndexNotFoundErrorDetails,EndpointNotFoundErrorDetails,EndpointErrorDetails,UniquenessErrorDetails,CardinalityErrorDetails,DisjointErrorDetails,RestrictedDeleteErrorDetails,VersionConflictErrorDetails,SchemaMismatchErrorDetails,MigrationErrorDetails,EagerMaterializationErrorDetails,StaleVersionErrorDetails,SchemaContentConflictErrorDetails,StoreNotInitializedErrorDetails,DatabaseOperationErrorDetails,EmbeddingDimensionChangedErrorDetails) are exported from the package root alongside the existingValidationErrorDetails. Classes with intentionally open, per-call-site details (ConfigurationError,UnsupportedPredicateError,CompilerInvariantError,BackendDisposedError) are unchanged. -
#206
995b964Thanks @pdlug! - perf: cascade deletes batch their edge removals — new optionalGraphBackend.deleteEdgesBatch/hardDeleteEdgesBatchmembers issue one statement per bind-budget chunk instead of one per connected edge (50-edge cascade on local PostgreSQL: 24.4ms → 3.6ms), with recorded-time capture preserved.getOrCreatevariants no longer run the full Zod parse twice on the create leg.
Patch Changes
Section titled “Patch Changes”-
#247
191e877Thanks @pdlug! - Fix: the synthetic CTE column names that carry selectively-extractedpropsfields are now bounded to PostgreSQL’s identifier limit.A selected top-level
propsfield is extracted once inside the CTE that owns it, under a generated column name encoding the query alias and the field name. The encoding was unambiguous but unbounded, and PostgreSQL silently truncates identifiers at 63 bytes — so two distinct(alias, field)pairs sharing a long prefix could collapse onto one column name after truncation, yielding an ambiguous-column error or the wrong value.Long names are now truncated on a UTF-8 character boundary and disambiguated with a hash of the full, untruncated pair — the same guard the sibling subgraph projection path already used, now extracted into one shared helper. Names that already fit are emitted unchanged, so compiled SQL for ordinary queries is byte-for-byte what it was.
-
#247
191e877Thanks @pdlug! - Document a semantic consequence of batched writes: within one backend batch call, every row whose timestamp TypeGraph generates shares a single instant, sampled once for that call — not once per row, and not once per bind-budget chunk.bulkCreate()andbulkInsert()issue one such call, so all of their rows tie.Creating the same rows one at a time through
create()gives each its own timestamp, soORDER BY created_atwas a total order there and is only a partial one after a bulk write. Two things it is not safe to conclude:importGraph()is not one instant. It slices nodes and edges intobatchSizebatches and drives one backend call per slice, so each slice samples its own timestamp. Rows that carry an explicitvalidFromin the import payload keep it verbatim; only generated defaults are affected.- Ids are not a sequence. The default generator is a random NanoID, and
callers may supply arbitrary ids, so
ORDER BY idis not insertion order.(created_at, id)is a deterministic tiebreak, not a chronology. If input order matters, persist an explicit sequence column.
One instant per batch call is the intended semantics — it is what makes a bulk write a single point in valid time rather than a smear — and it is the same choice
valid_fromalready made. Nothing changes in behavior; this note exists because the batching work that landed this release moved several paths onto it. -
#248
c379045Thanks @pdlug! - Perf: cache compiled query SQL across executions again, without freezing the read instant.The read-freshness fix recompiled a query’s full AST to SQL on every
execute()so a reused or prepared query would always see the latest rows. That kept results fresh but made the recommended.prepare()-once-.execute()- many pattern pay a full compile per call (a point lookup ~58µs, a three-hop traversal ~450µs of pure JS compilation).Only the bound “current” read instant varies between two compilations of the same query; the SQL text is identical. So a query now compiles once into a cached statement whose read instant is a reserved execution-time placeholder, and each execution fills a fresh instant into it and runs the cached text directly. Repeated point-query execution drops from ~47µs to ~2.4µs (near the raw-execution floor) while staying just as fresh — a row created after
prepare()or the firstexecute()is still visible on the next call.The cache applies to
ExecutableQuery, prepared queries, aggregate queries, and set operations, on backends that can compile and run raw SQL text (synchronous SQLite and PostgreSQL backends); other backends — including async SQLite profiles that do not exposeexecuteRaw— fall back to per-call recompilation unchanged. Statements whose execution depends on the compiled SQL object — pgvector approximate-scan GUC tuning and parameter-blind-plan avoidance — keep running through the standard execution path.param()now rejects the reserved read-instant name, and aggregate queries (which have no.prepare()) rejectparam()with clear guidance instead of a downstream binding error. -
#244
b38a537Thanks @pdlug! - Fix: “current” temporal reads now evaluate validity against the application clock, not the database clock — repairing a read-after-write consistency violation on Postgres.valid_fromis stamped from the application clock (Date.toISOString()) on write, but a “current” read compiled its validity filter against the database clock (valid_from <= NOW()on Postgres). On any deployment where the application-server clock runs ahead of the database-server clock — i.e. the app and database on separate hosts, which is the norm — a freshly-created node or edge could be missing from the very “current” read that immediately followed its creation, until the database clock caught up. SQLite (a single in-process clock) was never exposed.The “current” read now binds the application clock (
nowIso()) as a parameter — the same clockvalid_from, the facade search-currency filter, and the recorded/logical clock already use — across every current-read path (standard and recursive queries, subgraph extraction, graph algorithms, and recorded-time reads). The temporal-visibility clock is now a single source. Because the current-read instant is no longer dialect-specific, the internalDialectAdapter.currentTimestamp()seam has been removed.Know the consistency model this buys you. Reads and writes now share one clock — the clock of the process that issued them. Read-after-write consistency therefore holds per application process: a node you just created is visible to the very next current read from that same process, which is the guarantee the bug broke. It does not extend across processes. Two application servers with skewed clocks, writing to one PostgreSQL database, can still miss each other’s fresh rows: a row stamped
valid_from = Tby the server that runs ahead stays invisible to a current read from the server that runs behind until its own clock passesT. The window equals the skew between the two application hosts, not between an application host and the database. If you need cross-process read-after-write consistency, keep application clocks disciplined (NTP), or read at an explicitasOfcoordinate rather thancurrent. -
#247
191e877Thanks @pdlug! - Fix:store.algorithms.degree()undercounted edges written before an endpoint declaration changed.To let the composite edge indexes seek — both lead with the endpoint kind column, so a bare
from_id = ?cannot — the direction filter supplied the missing kind equality by enumerating the endpoint kinds the graph declaration permits for the counted edge kinds. That enumeration is complete only for rows written under the current declaration. Narrowknowsfromfrom: [Person]tofrom: [Employee], and everyPerson-rootedknowsedge already on disk drops out of the filter:degree()silently returns a number too small, with no error and no warning.The filter now derives the kind from the counted node itself, via an uncorrelated scalar subquery. This is exact by construction: an edge row stores the actual kind of each endpoint node (the write path copies it off the endpoint reference) and a node’s kind is immutable for the life of its id, so for any edge incident to a node, the endpoint kind on that node’s side is that node’s kind and nothing else — however the declaration later evolves.
It is also a better filter. An equality on one kind replaces an
INlist over every declared endpoint and itssubClassOfdescendants, and both engines hoist the uncorrelated subquery to a constant (a Postgres InitPlan, a SQLite one-shot scalar subquery), so the seek is unchanged.EXPLAIN QUERY PLANstill showstypegraph_edges_from_idx/_to_idxseeks with no partition scan.degree()of an id that names no node is0, as before. -
#200
472ac1cThanks @pdlug! -degree()direction filters are now shaped for the default edge indexes. The filters previously compiled to barefrom_id = ?/to_id = ?, which neither composite edge index can seek (both lead with the endpoint kind column) — so degree counts relied on engine-specific rescue: SQLite skip-scan (only with fresh statistics) or PostgreSQL 18’s new btree skip scan, and degenerated to partition scans everywhere else (PostgreSQL ≤ 17, SQLite with stale statistics).The filters now enumerate the endpoint kinds the graph declaration permits for the counted edge kinds, expanded through the subClassOf closure — the same set edge writes validate against — making
edges_from_idx/edges_to_idxstructurally seekable on every engine and version. Measured on PostgreSQL 18 (where the old form was already skip-scan rescued): 0.30ms → 0.06ms per call; on older PostgreSQL the old form could not use these indexes at all. An edge set that declares no endpoint kinds on the required side now returns 0 without a round trip.Behavior note: because the counted set is now restricted to edges whose stored endpoint kind falls within the declaration’s
subClassOfclosure,degree()no longer counts an edge whose storedfrom_kind/to_kindlies outside that closure — e.g. a row written before the endpoint declaration was narrowed, or written directly through the backend bypassing endpoint validation. This matches how typed traversals already treat such rows (invisible to a schema-consistent read), but it is a change from the previous “count every edge touching this node regardless of stored kind” behavior. -
#220
7b48543Thanks @pdlug! - Edge delete, edge hard delete, and node hard delete no longer re-read the row inside the write transaction. The in-transaction preflight was pure round-trip fat on these paths: nothing consumed the row, and the writes are already concurrency-correct on their own — the tombstone UPDATE is guarded bydeleted_at IS NULLand the hard deletes are id-keyed and idempotent, so a row deleted concurrently between the outside gate and the write lock degrades to a 0-row no-op with identical observable behavior (verified including recorded-time history under a deliberately staled gate). One less statement per delete (~20% of the per-op round trips on client/server engines). Node SOFT delete keeps its preflight deliberately: its pipeline consumes the pre-image for uniqueness-key cleanup, now documented in place. -
#227
09754a6Thanks @pdlug! - Batches edge creation’s endpoint-existence checks inbulkCreate/bulkInsertinto onegetNodescall per distinct (kind) referenced across the whole batch, instead of an individualgetNodeprobe per edge (mirroring the batched existence/uniqueness pre-check node creation already had viaprimeBatchValidationCaches). Found while investigating why a real LDBC SNB SF1 bulk load (millions of nodes and edges) was far slower than expected: a controlled 1M-row reproduction showedbulkInsertedge-batch time growing from ~90ms to ~630ms per 2,000-row batch as the graph grew, while an equivalent node-only batch (no edges) stayed roughly flat. The edge batch path validated each edge’sfrom/toendpoints with agetNodecall per edge — for a batch with mostly-unique endpoints, that’s thousands of individual round trips per batch instead of one batched fetch per distinct node kind. With the fix, the same 1M-edge reproduction’s per-batch time drops to roughly ~90-160ms and its growth curve flattens substantially (the residual growth matches the same mild index-maintenance cost already seen on plain node inserts). No behavior change: this is a pure internal optimization toexecuteEdgeCreateNoReturnBatch/executeEdgeCreateBatch; callers observe identical results, just fewer round trips. -
#245
ef6def6Thanks @pdlug! - The default edge traversal indexes ({table}_from_idx/{table}_to_idx, created for every graph on both SQLite and PostgreSQL) were missing two things a traversal join needs to be served fully index-only:valid_from— one of the three system columns every compiled query’s soft-delete / temporal-validity predicate checks (deleted_atandvalid_towere already covered;valid_fromwasn’t).- The join’s target-id column — a compiled traversal reads
n.id = e.to_idfor an outgoing traversal, orn.id = e.from_idfor an incoming one (standard-builders.ts), but neither index carried the other endpoint’s id column, so the join to the target node still required a heap-row fetch even once the predicate columns above were covered.
Both gaps produce the same symptom: SQLite’s plan reads
USING INDEX, neverUSING COVERING INDEX, so every candidate edge pays a heap-row fetch. That fetch is free while the table fits in the page cache. Once it doesn’t — a real LDBC SNB benchmark run measured this at 10x data volume, where the nodes table outgrew available cache — every one of those fetches becomes a genuine random disk read, and with thousands of candidates per traversal that alone produced a multi-second/minute latency cliff on an otherwise sub-millisecond query shape. Both indexes now carry all five columns beyond their existing seek prefix (deleted_at,valid_from,valid_to, plus the other endpoint’s id), confirmed viaEXPLAIN QUERY PLANagainst the actual SQLexecute()sends (nottoSQL()’s wider, unoptimized output) to flip toUSING COVERING INDEX.Existing databases get none of this until you rebuild the indexes. The widened indexes materialize on fresh databases only.
generateSqliteMigrationSQL()/generatePostgresMigrationSQL()emitCREATE INDEX IF NOT EXISTSunder the same index name, and that is a no-op against an index that already exists — regardless of how the column list changed. An upgraded deployment silently keeps its narrow index, and keeps the latency cliff, until it runs the rebuild below. Upgrading the package is not enough; there is no automatic migration.-- SQLite: no CONCURRENTLY equivalent; drop and let the next migration-- run (generateSqliteMigrationSQL(), or a createStoreWithSchema boot,-- which re-issues idempotent DDL) recreate them.DROP INDEX IF EXISTS typegraph_edges_from_idx;DROP INDEX IF EXISTS typegraph_edges_to_idx;-- PostgreSQL: CREATE INDEX CONCURRENTLY does not block writes, but it-- cannot run inside a transaction and needs its own connection. Rename-- the old index out of the way first so the new one can use the-- production name without a window where neither exists.ALTER INDEX typegraph_edges_from_idx RENAME TO typegraph_edges_from_idx_old;CREATE INDEX CONCURRENTLY "typegraph_edges_from_idx" ON "typegraph_edges"("graph_id", "from_kind", "from_id", "kind", "to_kind", "deleted_at", "valid_from", "valid_to", "to_id");DROP INDEX CONCURRENTLY typegraph_edges_from_idx_old;ALTER INDEX typegraph_edges_to_idx RENAME TO typegraph_edges_to_idx_old;CREATE INDEX CONCURRENTLY "typegraph_edges_to_idx" ON "typegraph_edges"("graph_id", "to_kind", "to_id", "kind", "from_kind", "deleted_at", "valid_from", "valid_to", "from_id");DROP INDEX CONCURRENTLY typegraph_edges_to_idx_old; -
#217
fce0a0fThanks @pdlug! - Non-approximate.similarTo()is now genuinely exact when an ANN index exists. pgvector serves anyORDER BY embedding <=> q LIMIT kfrom a matching HNSW/IVFFlat index, so aftermaterializeIndexes()the default (non-approximate) inline vector predicate silently returned approximate results — measured recall 0.980 unfiltered and 0.000 under a selective filter at 50k docs, where the index frontier starves at the default ef_search and returns entirely wrong rows. The exact branch now orders by(distance + 0.0), which the index opclass cannot match, forcing the true flat scan on every engine (numerically identity; inert on SQLite/libSQL whose ANN forms are opt-in constructs).Behavior change: exact queries that were silently index-served get correct results and flat-scan latency (50k x 384 dims: ~39ms instead of ~23ms-but-wrong). The sanctioned fast path remains
similarTo(..., { approximate: true }), which is unchanged. Thebench:vectorlane’svector:exact-postindex-recallandvector:exact-filtered-postindex-recallrows now read 1.000. -
#210
76422c6Thanks @pdlug! - perf: PostgreSQL fulltext queries are now parsed with the kind’s DECLARED language as a plan-time constant (the same winning-language rule the write path applies to rows), instead of referencing the per-rowlanguagecolumn. The per-row form made every tsquery non-constant, so the GIN index ontsvcould never serve a match and every search re-parsed the query per row — measured 12.9ms → 2.3ms at 5,000 docs for the parse elimination alone, with GIN service now possible as corpora grow. Applies to the facade and the inline$fulltextpredicate; mixed-language subclass aliases and explicit per-query overrides behave as before. -
#207
5cbcb35Thanks @pdlug! - perf: recorded-time capture acquires the PostgreSQL graph-write advisory lock once per transaction instead of once per captured write (pg_advisory_xact_lockis reentrant and held to transaction end, so the repeats were pure round trips). A 50-write recorded transaction drops from N+1 lock round trips to 1; measured 1.7× on the transaction shape. -
#215
0eb2fd8Thanks @pdlug! - The single-statement hybrid search now emits the candidates set (liveness/currency filter, or the compiledwherepredicate query) once, as a CTE shared by the vector and fulltext legs, instead of embedding — and re-executing — a private copy inside each leg. The duplicate evaluation was most expensive with awherefilter, whose compiled candidates query ran twice per search: measured on PostgreSQL, filtered hybrid drops 26.5ms → 17.1ms at 5k docs (bench shape 11.8ms → 8.6ms; unfiltered 6.1ms → 4.9ms). This also removes a subtle inconsistency where each leg stamped its own currency instant. SQLite is unchanged within noise (in-process re-execution was cheap). -
#247
191e877Thanks @pdlug! - Fix: hybrid search’s two execution paths agreed on scores but not on ties, and neither was deterministic across PostgreSQL databases.Relevance ranking breaks a score tie on
node_id. Left bare, PostgreSQL sorts that under the database’s default text collation: anen_US.UTF-8database ordersa, A, b, Bwhere byte order givesA, B, a, b. So the same query returned different pages on two databases whosedatcollatediffered, and disagreed with SQLite (whoseBINARYcollation is byte order) throughout.Three seams had to move together, because a hybrid search’s tiebreak decides the page twice — once in the per-source ranks, and again in the fused ordering the ranks produce:
- The single-statement hybrid search now renders
node_id COLLATE "C"in both per-sourceROW_NUMBER()windows and in the finalORDER BY. - The standalone fulltext search’s
ORDER BY … , node_idis C-collated too, so the multi-statement fallback’s fulltext ranks match. - The fallback now re-ranks each leg’s rows before assigning ranks, rather than
trusting the order the source SQL happened to return for a single kind. The
vector source breaks a distance tie arbitrarily — it carries no
node_idtiebreak, because a second sort key would cost pgvector its ordered index scan — so its arrival order was never a sound basis for a rank. That re-rank sorts with a new code-point comparator rather than JavaScript’s UTF-16 code-unit<, which disagrees with byte order for astral characters such as emoji.
All three orderings now coincide, and the single-statement and multi-statement paths return identical hits, ranks, and scores even when every score ties.
Results only change where they were previously non-deterministic.
- The single-statement hybrid search now renders
-
#213
a243f3bThanks @pdlug! -importGraph’s defaultbatchSizeis now 1,000 (was 100), and the default now actually applies: options are parsed throughImportOptionsSchemaat the function boundary, so direct calls that omit fields with schema defaults (e.g.{ onConflict: "error" }) resolve them instead of readingundefined.ImportOptionsis now the schema’s input type — fields with defaults are optional for callers.Each import batch pays fixed per-round-trip costs (existence probe, unique pre-check, one multi-row insert), so the old default dominated import time on client/server engines: a 20k-node + 5k-edge import on PostgreSQL drops from 1,515ms to 781ms (16.5k → 32k entities/s). SQLite imports are insensitive to the value (in-process, no round trips). Explicit
batchSizevalues are unaffected.Fulltext batch upserts and deletes are now split by the driver’s bind-parameter budget in the backend wrappers, like node/edge/unique inserts already were. Previously a searchable import slice emitted ONE FTS5 (or tsvector) statement over every row — 6 binds per row, so a 1,000-row slice overflowed SQLite’s 999-bind fallback ceiling and D1’s ~100-bind cap, and 6,000-row slices overflowed even better-sqlite3’s 32,766 budget (“too many SQL variables”).
-
#221
9b61809Thanks @pdlug! - Inline.similarTo(..., { approximate: true })now actually uses the ANN index on PostgreSQL. Two defects compounded: the candidates membership subquery carried aDISTINCTthat kept the planner off the ordered index scan entirely (evenenable_seqscan = offcould not rescue it — duplicates are irrelevant toINmembership, so the DISTINCT bought nothing), and the inline path never applied the pgvector GUCs the search facade uses, so even an index-served filtered scan would have starved at the default ef_search frontier. The compiler now emits duplicate-tolerant membership candidates for the engine-form branch and brands ANN-bearing statements; the PostgreSQL backend wraps branded statements with the facade’s GUC overrides (hnsw.iterative_scan = strict_order/ivfflat.iterative_scan = relaxed_orderon transaction-capable drivers with pgvector >= 0.8; the settings are transaction-scoped, so non-transactional backends such as neon-http keep the plain bounded scan). Set operations merge operand brands onto the combined statement, so a union with an approximate operand is wrapped too. Measured at 50k x 384 dims: unfiltered approximate 174ms -> 2.1ms (recall 0.995), filtered approximate 3.8ms at recall 1.000 on filter-independent corpora. The JOIN consumers of the scoped candidates (exact branch, fulltext CTE) keep their DISTINCT — a join does multiply rows on duplicates — and the non-approximate path’s exactness guarantee is untouched. -
#224
b5886cdThanks @pdlug! - Document external event-log materialization patterns and verify the export/import bulk-copy path into graph-merge branches. -
#199
d01d6c7Thanks @pdlug! - Subgraph extraction is ~4× faster on PostgreSQL. The final node/edge fetches filtered ids withIN (SELECT id FROM included_ids); PostgreSQL pulls that form up into a join whose recursive-CTE row estimate (~10 rows for a single-row seed) drives the planner into a nested-loop join filter — measured at ~10 million discarded rows on the depth-3 benchmark shape. PostgreSQL now evaluates membership against the materialized closure ids with a parameterizedtext[]semi-join (EXISTS (SELECT 1 FROM unnest($ids) AS t(id) WHERE t.id = column)) rather than pulling the recursive CTE into that join; SQLite keepsIN (subquery), which it already evaluates optimally.Measured (benchmark suite, 1,200 users / depth-3 stress shape): PostgreSQL subgraph full hydration 322ms → 82ms, depth-2 11.5ms → 7.1ms; SQLite unchanged.
-
#247
191e877Thanks @pdlug! - Fix: serialize the statements TypeGraph issues on a transaction’s pinned Postgres connection, so its own graph writes never present two queries to one connection at once.A transaction pins one connection, and the PostgreSQL wire protocol carries one statement at a time. node-postgres hid that behind an internal queue, deprecated it in
pg@8.22(“Calling client.query() when the client is already executing a query is deprecated and will be removed in pg@9.0. Use async/await or an external async flow control mechanism instead”), and removes the queue inpg@9. TypeGraph overlapped statements on a pinned connection in two ways:- Always on, no user concurrency required. The node write pipeline issues
Promise.all([syncEmbeddings, syncFulltext])for any schema that has both asearchable()field and anembedding()field, so every singlecreate(),update(), or resurrect on such a schema put two statements on the wire. - User-driven.
store.transaction(async (tx) => { await Promise.all([...]) })is a documented, recommended pattern.
Transaction-scoped backends now run every statement they issue through a per-connection queue. Concurrency at the API surface is unchanged — a
Promise.allof graph writes still works, and on a pooled (non-transactional) backend the statements still run genuinely concurrently. The queue serializes only what already had to be serial. A multi-statementSET LOCAL-scoped vector search (snapshot / set / select / restore) runs as one exclusive group, so two concurrent searches can no longer interleave and apply each other’sefSearch.The transaction boundary also drains and closes the queue before the driver emits
COMMIT/ROLLBACK. Those control statements do not travel through the queue, so without the drain a rollback could overlap a live statement. And a callback that rejects out of aPromise.allleaves its siblings running: their statements would otherwise land on the connection after the pool had reclaimed it, executing inside an unrelated transaction. Such a statement is now refused with a newTransactionClosedError(normally invisible —Promise.allhas already rejected with the original failure and discards this one).Scope: the queue mediates only TypeGraph’s own statements. The raw Drizzle handle exposed as
tx.sql(for writing your own relational tables in the same atomic boundary) bypasses it. Running a raw statement concurrently with a graph write — or with another raw statement — still races on the one pinned connection, anddrainAndClosecannot wait for a raw statement it never saw. Await eachtx.sqlstatement before the next write; this is inherent to a single-connection transaction, not something TypeGraph can enforce over a handle it doesn’t mediate.adoptTransaction()likewise serializes the statements it issues but never closes the queue — the caller owns that transaction’s end. - Always on, no user concurrency required. The node write pipeline issues
-
#219
ee93b77Thanks @pdlug! - Statements whose good plan depends on their parameter values (the subgraph id-array fetches, marked internally with the custom-plan brand) now opt out of statement preparation per call on the postgres-js driver too, viasql.unsafe(text, params, { prepare: false }). Previously postgres-js prepared them like everything else, so after five executions PostgreSQL flipped them to a generic, parameter-blind plan — the same cliff fixed for node-postgres in the subgraph shared-traversal change (measured there: 21ms → 310ms on the edge fetch). Scalar-parameter statements keep the driver’s prepared default. -
#246
d5aafe8Thanks @pdlug! - Critical fix:.prepare()d queries, and anyExecutableQuery/UnionableQuery/ExecutableAggregateQueryinstance whose.execute()was called more than once, could silently miss rows created after the query was first compiled.A “current” (live) temporal-validity read binds its read instant (
currentReadInstant()) at SQL compile time. All four query-builder classes cached their compiled SQL text across calls —.prepare()compiled once and every subsequentexecute({...})reused that same SQL text, and a reusedExecutableQuery/UnionableQuery/ExecutableAggregateQueryinstance cached its first.execute()’s compilation the same way. Both patterns froze “now” at the moment of first compilation: any row created afterward had avalid_fromlater than the frozen instant, sovalid_from <= nowsilently evaluated to false for it, for the query’s entire remaining lifetime.This is a regression introduced by the
current-read-app-clockfix (the #242 clock-skew correction): the prior behavior (NOW()/strftime('now'), evaluated fresh by the database on every execution) did not have this problem. It is more severe than #242 — that bug required app/DB clock skew across separate hosts; this one reproduces unconditionally, in a single process, on the very next insert after a query is prepared or first executed..prepare()-once-.execute()-many is this library’s own documented, recommended pattern, so this affected the common case, not an edge case.Fix: none of the four classes cache compiled SQL text across calls anymore — each
execute()/compile()/toSQL()call recompiles fresh, socurrentReadInstant()is re-evaluated every time..prepare()still builds and structurally validates the query AST once (so a malformed query still fails fast, before the firstexecute()); only the SQL-text compilation moved from prepare-time to each execute-time call.param()-bound values are unaffected — those were already correctly re-bound per call. -
#209
5e24882Thanks @pdlug! - perf: facade search candidate handling planned poorly at scale. The hybrid statement’s fused CTE is now MATERIALIZED (PostgreSQL inlines single-use CTEs, re-executing the fusion subtree once per candidate node row under a nested-loop join), and unfiltered facade searches use a flat, parameter-bound current-read candidates subquery instead of a compiled builder query whose per-row SQL clock calls dominated on SQLite. Semantics are unchanged — validity windows and tombstones are still enforced, with the instant bound as a parameter. Only searches with awherepredicate compile a builder query as candidates;includeSubClassesexpands at the store level and each concrete kind uses the flat form. -
#202
b45cfc3Thanks @pdlug! - fix: facade search (store.search.vector/fulltext/hybrid) now computes top-k over live nodes in SQL. Previously the search statement ranked side-table rows alone and hydration dropped tombstoned ids afterward, silently returning fewer thanlimithits under index drift. Liveness is pushed into the KNN/MATCH SQL on every engine — exact on pgvector ≥0.8 (HNSW viahnsw.iterative_scan = strict_order; IVFFlat viaivfflat.iterative_scan = relaxed_orderwith an in-statement re-sort), sqlite-vec (vec0 primary-keyINpushdown), tsvector, and FTS5; libSQL DiskANN over-fetches 4× and post-filters (documented recall bound). -
#237
48f324bThanks @pdlug! - Fixes.select()query projections losing theNodeId<N>brand on nodeidfields. Previouslyctx.alias.idin a.select()callback was typed as plainstring, so feeding a projected node id back intogetById/getByIdsrequired an unsafe cast (as neveror worse).SelectableNode<N>.idis now typedNodeId<N>, matching whatgetById/getByIdsalready require — no runtime change, no cast needed.Edge ids from
.select()stay plainstringon purpose:traverse()defaults toexpand: "inverse", which can back an edge alias with a row of the registered inverse edge kind, so the alias’s static edge type doesn’t reliably describe the row. UseasEdgeIdto re-brand a projected edge id before a point read. -
#247
191e877Thanks @pdlug! - Fix: a set operation now binds one “current” read instant across all of its operands.UNION/INTERSECT/EXCEPTcompile each operand independently, and each operand compiled its own temporal-validity filter from a freshnowIso()sample. A compoundSELECTis evaluated against a single snapshot, so two samples microseconds apart let the two halves of anINTERSECTorEXCEPTdisagree about whether a row created between them is current — a row could satisfy the left operand’svalid_from <= nowand not the right’s.Compilation of a set operation (including nested ones) now runs under a single pinned instant. Ordinary single-leaf queries were already consistent — they bind one instant per compile — and are unaffected.
-
#226
4cd6b4cThanks @pdlug! - Fixes a scaling bug in the SQLite backend’srefreshStatistics()(the planner-statistics refreshbulkCreate/bulkInserttrigger automatically after a large autocommit write — see theautoRefreshStatisticsstore option). It ran a bare, unscopedANALYZE, which does two things wrong on SQLite: it re-analyzes every table in the database file (not just TypeGraph’s own tables — already fixed on the Postgres backend), and it does a full, unbounded table/index scan per call (Postgres’sANALYZEsamples a fixed-size set of rows regardless of table size; SQLite’s does not unless bounded). A caller streaming a bulk load through repeatedbulkInsert()calls — the only practical way to load a multi-million-row dataset without holding it all in memory — re-triggers this once each batch’s row count crosses the threshold; with unbounded per-call cost growing with total table size, total load time integrated to O(n²) instead of O(n) (observed: a 2M-row bulk load that never finished after 4.5+ hours).refreshStatistics()on SQLite now scopes ANALYZE to TypeGraph’s own tables and setsPRAGMA analysis_limitfirst, bounding each call’s cost the way Postgres’s already was. A 100k-row reproduction of the original shape now completes in ~8s with load time growing log-ishly with table size (2x from first batch to last), not quadratically. -
#218
b601484Thanks @pdlug! - Non-approximate.similarTo()on SQLite now routes through sqlite-vec’s vec0 KNN form. vec0’s KNN is brute-force in C — exact by construction — so the default path keeps identical results (pinned against JS-computed ground truth) while dropping from the SQL distance scan to engine speed: 489ms → 124ms for top-10 over 50k 384-dim embeddings. Declared via a newsearchIsExactflag on the vector-strategy contract; pgvector and libSQL leave it unset (their engine forms are approximate) and are unchanged. The metric gate still applies: an explicit metric override that differs from the slot’s declared metric falls back to the SQL scan, which is correct for any metric. -
#211
a216569Thanks @pdlug! - Subgraph extraction on PostgreSQL now runs the recursive traversal once instead of twice. The node and edge fetches previously each embedded the full recursive CTE; the closure ids are now fetched in one statement and passed to both fetches as a singletext[]parameter, filtered via anEXISTSsemi-join overunnest. Those id-filtered fetches execute as unnamed statements so PostgreSQL plans them against the actual array on every call — a named prepared statement flips to a generic plan after five executions, which mis-plans array-cardinality-dependent filters (measured 21ms → 310ms on the edge fetch). Depth-3 stress subgraph (1,109 nodes / 4,513 edges, wide payloads): 82.9ms → 30.9ms full hydration, 72.3ms → 15.6ms with SQL projection. SQLite keeps its existing single-statement-per-fetch form, which is already optimal for an in-process engine. -
#234
d042a30Thanks @pdlug! - perf: push selected top-levelpropsfield extractions into the start/traversal CTEs instead of carrying the whole rawpropsJSONB/JSON column outward for later extraction at the final projection. Each selected field is extracted once, inline, as its own typed CTE column (named from a length-prefixed encoding of its alias and field, so distinct alias/field pairs can never collide on the same column name); the outer projection and any matchingORDER BYon the same field just reference that column directly instead of re-extracting from a carried-forward<alias>_propscolumn.Found while investigating why a covering index on a system column (see
keySystemColumns) still couldn’t get Postgres to serve an indexed join index-only: the compiled query was asking for the entirepropscolumn in the join step even though the final.select()only needed one extracted field, so the specific indexed expression was never actually what got read from the table. No behavior change: compiled query results are identical; this only changes which columns each CTE carries and where field extraction happens. -
#242
6b884b6Thanks @pdlug! - Fix: creating a node or edge without an explicitvalidFromnow stamps the operation’s own creation timestamp instead of storing SQLNULL.NULLis interpreted by temporal filters as open-left validity (“valid since forever”), so a record created withoutvalidFromwas visible at any historicalasOfinstant — including ones before the record existed. This contradicted the documented contract (“omittedvalidFromdefaults to now”) and is fixed at the insert layer for every write path:create,createFromRecord,upsertById/upsertByIdFromRecord(create branch),bulkCreate,bulkInsert,bulkUpsertById, and get-or-create, for both nodes and edges.branch()’s working-copy clone now also exports withincludeTemporal: true, so a fork’svalidFrom/validToexactly match the base’s — without this, the clone would re-stamp any implicitvalidFromto the fork’s own (later) creation time, narrowing the fork’s valid-time window relative to the base it was cloned from. This includes rows that still have aNULLvalid_from(predating this fix, or written directly via the backend):exportGraph/importGraphnow round-trip a confirmed open-left window as an explicitnullrather than silently dropping it, so a legacy row’s “valid since forever” semantics survive a clone unchanged instead of being narrowed to the clone’s own creation time.exportGraph/importGraphround trips still defaultincludeTemporaltofalse; without it, imported records get a freshvalidFromat import time rather than the source’s original value (see the Interchange docs).Custom
GraphBackendimplementations that build their own inserts (rather than reusing the bundled Drizzle operation builders) should apply the same rule: an omittedvalidFromdefaults to the row’s creation instant, and an explicitnullis preserved as SQLNULL(open-left). -
#214
583fbb3Thanks @pdlug! - PostgreSQL ANN index builds (materializeIndexes()on pgvector HNSW/IVFFlat) now retry serially when the parallel build exhausts shared memory. Parallel builds stage the index graph in dynamic shared memory, and resource-constrained hosts — e.g. containers with the 64MB/dev/shmdefault — reject the allocation with SQLSTATE class 53 (observed: 53100 fromdsm_impl_posixon a 50k x 384-dim HNSW build). The retry drops the INVALID leftover from the failed CONCURRENTLY build, pins the vector table toparallel_workers = 0, rebuilds in local memory, and restores the setting. Non-resource failures still surface as before. Serial builds are slower — raise/dev/shmandmaintenance_work_memwhere you control the host — but a slow index beats a silently missing one.
0.34.0
Section titled “0.34.0”Minor Changes
Section titled “Minor Changes”- #188
0b0f4eaThanks @pdlug! - Add the@nicia-ai/typegraph/provenancesubpath for provenance-backed source retraction. The first slice maps user graph kinds to source, justification, fact, premise, and derivation roles; supports multiple source node kinds and terminal fact kinds; requires{ history: true }; applies TypeGraph-managed belief transitions by making unsupported facts non-current; and keeps recorded-time replay available before and after retraction. A transition only touches facts reachable from the flipped sources, and closing a fact’s currency is a belief-status change rather than a domain delete — the fact’s edges are left untouched (norestrict/cascade/disconnectenforcement), sounRetractis an exact inverse ofretract. PostgreSQL transitions serialize with TypeGraph-managed history writes on the same graph; out-of-band SQL remains outside recorded capture.
Patch Changes
Section titled “Patch Changes”-
#188
0b0f4eaThanks @pdlug! - Stop opening a write transaction ongetOrCreateByConstraint’s found path. The single-item node getOrCreate wrapped its whole body — probe included — in a transaction, so the common “already exists” case paid forBEGIN IMMEDIATEon SQLite (and, under history capture, the per-graph advisory lock on Postgres), and the nested create’s operation hooks fired inside that outer transaction, reporting success before a COMMIT that could still fail. The probe now runs as a pure read; the create and update/resurrect legs each open their own (hooked) transaction, soonOperationEndmeans durably committed. A concurrent create that reserves the key between the probe and the insert surfaces as a uniqueness conflict and is converged by a single re-probe. The bulk variant keeps its one enclosing transaction (atomic batch, hooks skipped by design). EdgegetOrCreateByEndpointsgets the same probe-first shape. -
#191
2cad229Thanks @pdlug! - GuardmergeIncremental()against inherited-row lost updates. The incremental commit path re-checked new-row identity resolution and per-row resurrect/strip hazards, but not whether a committed row the plan mutates still held the value the plan merged against — so a concurrent write to an inherited row between planning (reads taken outside the transaction) and commit was silently discarded. The commit now re-reads, in-transaction, every committed target row the plan will change and aborts with a retryableBaseVersionMismatchErrorif it drifted, matching the snapshot merge path’s TOCTOU contract. This covers all four mutating paths: node writes and node deletions (checked byversion), and edge upserts and edge deletions (checked by a content signature over endpoints, liveness, and canonical props, since edges carry no version column). -
#188
0b0f4eaThanks @pdlug! -importGraph(..., { onConflict: "update" })now skips soft-deleted target rows instead of failing. Import never resurrects a tombstone: a node or edge that exists only as a tombstone counts asskipped, keeps its tombstone, and gets no uniqueness/embedding/fulltext side effects (a uniqueness reservation held by a tombstoned node would block live creates of the same value). Previously the update path attempted a live-row update that threw and aborted the whole import.onUnknownProperty: "allow"is also pinned as the fidelity-preserving strategy: it validates known fields but persists the given properties byte-for-byte — no transform re-application, no default injection — so an export→import round trip cannot corrupt values whose schema transforms are not idempotent; use"strip"for a normalizing import. -
#188
0b0f4eaThanks @pdlug! - Fix a uniqueness-reservation corruption on a conflicting node update.updateUniquenessEntriesmutated one constraint’s sidecar at a time — releasing the old key before proving the new one free — so a caller that catches the resultingUniquenessErrorand still commits the transaction (notablyimportGraph(..., { onConflict: "update" }), which reports the conflict per row) left the node’s already-mutated sidecars in a corrupt state: an earlier constraint’s old key released (letting a later create silently duplicate it) or a new key wrongly reserved, while the row itself stayed unchanged. The update now runs in two passes — preflight every changed constraint’s new key first, then apply all sidecar deletes and inserts only after every key is proven free — so a conflict throws with zero partial writes, for every caller of the shared node-write pipeline and for nodes with any number of unique constraints. -
#188
0b0f4eaThanks @pdlug! - Make in-memory libsql databases safe across transactions, and fail loud on re-entrant root access. Local@libsql/clientconnections (file:paths andfile::memory:) now frame transactions with rawBEGIN IMMEDIATE/COMMITon the client’s single stable connection instead ofclient.transaction(), which permanently hands that connection to the transaction and lazily opens a fresh — for:memory:, empty — database afterwards (tursodatabase/libsql-client-ts#229). Remote Turso connections keep using the driver’s per-stream transactions. Separately, a store-level operation awaited from inside astore.transactioncallback on the same SQLite backend (root store instead of thetxcontext) used to deadlock permanently — the open transaction holds the backend’s serialized execution slot — and is now rejected with aConfigurationErrorthat points at the transaction-scoped context. -
#189
fe21158Thanks @pdlug! - Classify incompatible property-schema changes as breaking schema migrations. The migration diff previously compared only the top-level JSON-Schema token of each property, so a changed property type (e.g.string→number), a changed array item type (string[]→number[]), a narrowed enum, or a type change nested inside an object all auto-migrated silently as a non-blocking warning, leaving stored rows that no longer satisfy the declared schema; edge property changes were unconditionally treated as safe. Node and edge property diffs now share one recursive, conservative classifier: a change issafeonly when it can be proven non-breaking (a new optional property, a metadata-only edit, or an additive optional field nested inside an object). Everything else — a removed property, a newly required property, an in-place type change, a changed array item schema, an enum/const/composition change, a same-type constraint change, or a breaking change nested inside an object — isbreakingand blocks auto-migration. Thewarningseverity is no longer emitted for property changes. -
#190
1bfa9c2Thanks @pdlug! - Fix two silent query-correctness bugs. Keyset pagination (paginate/stream) now appends a uniqueidtiebreaker to the ORDER BY so a non-unique sort no longer drops equal-key rows across pages. And every compiledLIKE/ILIKEnow emitsESCAPE '\'— including the case-sensitivelikepath, which previously omitted it — so escaped%/_/\match literally on SQLite as they already did on PostgreSQL, in both the auto-escaped operators (contains/startsWith/endsWith) and rawlike/ilikepatterns, and whether the pattern is a literal or a bound parameter (previously SQLite had no default LIKE escape character, so the two backends — and the direct vs prepared paths — diverged). -
#188
0b0f4eaThanks @pdlug! - Fix a uniqueness-reservation loss on node resurrection. Resurrecting a soft-deleted node throughgetOrCreateByConstraint(or anyclearDeleted: trueupsert) ran the diff-based uniqueness maintenance, which skips a key that did not change — but the soft delete had already removed the node’s uniqueness entries, so the resurrected node held NO reservation and a latercreatewith the same unique value silently succeeded, duplicating it. A resurrecting update now re-checks and re-inserts the entries for its new props, exactly as the provenance reopen path does. -
#188
0b0f4eaThanks @pdlug! - Open SQLite business-write transactions withBEGIN IMMEDIATEon the sync (better-sqlite3) path, matching schema writes and the async libsql/Drizzle path. A deferredBEGINacquired the reserved write lock only on the first write, so a read-then-write inside a transaction could fail with “database is locked” against a writer on another connection to the same file; taking the lock at the start of the transaction lets SQLite’s busy timeout wait for it instead. The per-backend serialized write queue continues to order a single backend’s own transactions. -
#192
2af3a06Thanks @pdlug! - Type-check the remaining StoreView read-name buckets.CURRENT_ONLY_READ_NAMESandEDGE_BATCH_READ_NAMESwere plainas constarrays while every sibling bucket carried asatisfies readonly (keyof Collection)[]guard, so a renamed or mistyped method in those two would have gone uncaught at compile time. All six buckets are now checked against the live collection keys. Compile-time only. -
#188
0b0f4eaThanks @pdlug! - Operation hooks now mean “durably committed” everywhere.onOperationEndpreviously fired when an operation completed, even when that operation ran inside an enclosing transaction whose COMMIT later failed — so hook consumers (metrics, cache invalidation, audit logs) were told a rolled-back write succeeded. Operations insidestore.transactionnow defer their success hooks until the transaction commits, and a failed transaction converts every completed operation’s pending success intoonError. EdgegetOrCreateByEndpointsno longer wraps its write legs in an outer transaction (each leg commits — and reports — on its own, with a probe/create race converged by one retry), and provenance transitions route their source-flip and per-fact hooks through the same deferred lifecycle. Inside an adopted transaction (withTransaction/withRecordedTransaction) the commit belongs to the caller and cannot be observed; hooks there keep firing at operation completion, as documented.
0.33.0
Section titled “0.33.0”Minor Changes
Section titled “Minor Changes”-
#186
655407aThanks @pdlug! - Add recorded / system-time capture — TypeGraph’s second temporal axis. Where valid time (validFrom/validTo, queried viaasOf/includeEnded) records when a fact was true in the world, recorded time records when TypeGraph captured a managed node/edge write. Together they answer “what did TypeGraph reconstruct as true, as of a captured commit instant?” — surfacing values that were later corrected (à la SQL:2011 system-versioned tables).Enable capture per store with
createStore(graph, backend, { history: true }). TypeGraph collection writes through that store are then captured into recorded-time relations (typegraph_recorded_nodes/typegraph_recorded_edges), stamped with a per-graph monotonic commit instant from atypegraph_recorded_clock(serialized on PostgreSQL via a per-graph advisory lock). Capture is opt-in and has no backfill — enable it on a fresh graph, since an entity that already exists is first recorded the next time it is written. It requires a transactional backend with statement execution (the built-in SQLite / PostgreSQL backends).Read at a recorded instant with
store.asOfRecorded(T), which returns a narrow read-onlyRecordedStoreView. Directstore.asOfRecorded(T)is diagonal bitemporal sugar (recorded and valid axes both atT); chainstore.asOf(validT).asOfRecorded(recordedT)to pin the two axes independently, orstore.view({ mode }).asOfRecorded(recordedT)to compose recorded time with any valid-time mode (e.g.includeTombstones).store.recordedNow()returns the recorded high-water mark; after guarding theundefinedcase, passing that value tostore.asOfRecorded(...)is a deterministic “as things stand now” anchor. Recorded instants are monotonic and can run briefly ahead of wall-clock time under bursty writes, so the wall clock is not a reliable anchor right after a write.The recorded view is a reconstructing lens that exposes only reads which can be faithfully rebuilt from the history relations: point reads (
nodes.<Kind>.getById/getByIdsand the edge equivalents), a sealedquery(),subgraph(), and the graph algorithms (reachable/canReach/shortestPath/degree). Broad collection reads (find/count/findFrom),search, and fulltext / vector predicates refuse with aConfigurationError/UnsupportedPredicateError— those indexes reflect current state only.Tmust be a canonical UTC ISO-8601 timestamp (YYYY-MM-DDTHH:mm:ss.sssZ).The public live-read and algorithm option types explicitly reject internal recorded coordinates, while recorded internals use a branded
RecordedInstantso only validated canonical recorded instants can flow through the reconstructing paths.Recorded read binding is now explicit without exposing TypeGraph’s internal capture binding.
history: trueenables TypeGraph-managed capture and binds the built-in recorded relations internally, while the factory-brandedrecordedRelation({ schema })/recordedReadpath is the external-read-source API for hosts that populate a row-compatible recorded relation outside TypeGraph’s writer wrapper. The store validates that runtimerecordedReadvalues come fromrecordedRelation({ schema }), rejectsrecordedReadcombined withhistory: true, and factory-brands/freezes SQL schema and recorded-read descriptors so they cannot be structurally forged as plain objects. Store overloads reflect that split: history-enabled stores exposeHistoryStore, read-bound live stores exposeRecordedReadStore, and captured-history stores exposeHistorySafeBackend/HistoryTransactionContexttypes that hide raw statement / DDL write seams from the typedbackend,transaction(), andwithRecordedTransaction()surfaces.Writes under
history: trueflush capture at transaction commit, so they must go through the typed collections: rawtx.sqlis disabled (it would bypass capture), andstore.withTransaction(externalTx)is replaced by the callback formstore.withRecordedTransaction(externalTx, async (tx) => ...), which gives capture a flush point before the caller commits.store.clear()clears the recorded relations alongside the live tables.Node creates now run atomically on transactional backends with uniqueness, vector, and fulltext finalization, and node delete cascades now run atomically even without
history: true. A failed finalize step rolls back the node row instead of leaving a partially indexed row behind. Overlapping PostgreSQL cascades may hold locks longer, so callers should keep normal deadlock-retry handling around concurrent deletes.Backend and SQL execution contracts are more explicit for maintainers and extension authors: backend role brands separate graph-write paths from raw/bulk paths,
execute/executeStatementnow require row-vs-statement SQL intent brands, transaction backends are composed from explicit backend facets instead ofOmit<GraphBackend, ...>, and backend wrappers use an exact overlay helper that preserves prototype/proxy backends while catching typoed override keys at compile time.Exports
RecordedStoreViewand its collection types (RecordedStoreViewNodeCollection/RecordedStoreViewNodeCollections,RecordedStoreViewEdgeCollection/RecordedStoreViewEdgeCollections,TypedRecordedStoreViewEdgeCollection).Performance: recorded reads reconstruct from the history relations rather than the live tables, so they are slower than current-state reads — most noticeably for full-graph
subgraph/ algorithm reconstructions on PostgreSQL. UseasOfRecordedfor audit and point-in-time reconstruction, not hot-path reads.
0.32.0
Section titled “0.32.0”Minor Changes
Section titled “Minor Changes”-
#182
0f0e771Thanks @pdlug! - Close the TOCTOU windows in graph-merge commits. A merge resolves its plan from reads taken before the commit transaction, so a write landing on the target in between could previously be committed over. Now, inside the commit transaction:merge()andmergeAgainstBase()re-validate the target’s base@V content fingerprint, andmergeIncremental()re-runs its new-vs-base identity resolution (the unique-constraint and block-index probes). All three fail withBaseVersionMismatchError— instead of committing a stale plan or a duplicate entity — when the target changed in that window. Merge commits run atSERIALIZABLEisolation with bounded retry on serialization failures and deadlocks, making the guards race-free on multi-writer Postgres.Store.transaction()accepts optionalTransactionOptions(isolation level) andTransactionContextexposes the transaction-scopedbackend. -
#185
4e23be8Thanks @pdlug! - AddStoreView, a read-only(mode, asOf)lens over aStorethat pins a temporal coordinate and routes every supported read through it (the as-of database value, à la Datomic(d/as-of db t)/ SQL:2011FOR SYSTEM_TIME AS OF). Construct one withstore.asOf(T)(valid-time) orstore.view({ mode, asOf })for the other public modes (current/includeEnded/includeTombstones). The view exposes pinnednodes/edgescollections (getById/getByIds/find/count, edgefindFrom/findTo), a pre-pinnedquery(),subgraph(), and the graph algorithms (reachable/canReach/shortestPath/neighbors/degree). It is read-only by construction — writes and temporally-unscoped reads refuse with a clear error — andsearchrefuses on a non-currentpin (the fulltext / vector index reflects current state only).Internally every pinned surface injects a single opaque
ReadCoordinatethrough one helper, so a future temporal axis (recorded / system time) lands on every surface at once instead of splitting per surface. The view’s read surface is derived from a read/write split of the live collection types (NodeTemporalReads/NodeCurrentReads/NodeWritesand edge equivalents, now exported) with atest-dconformance check, so a new collection read cannot silently bypass the view’s pinning decision.store.snapshot(). Sugar forstore.asOf(new Date().toISOString())— a read-only view pinned to the current instant captured once at construction. Unlikestore.view({ mode: "current" })(which tracks “now” live), a snapshot is a stable point-in-time value where every surface observes the same instant. Mirrors Datomic’s(d/db conn).- Sealed pinned query.
view.query()now returns a query builder whose temporal axis is sealed — calling.temporal(...)on it throws — so a pinned view cannot be silently re-coordinated per query. - Current-only reads. Constraint / index lookups (
findByConstraint,bulkFindByConstraint,bulkFindByIndex), which have no temporal axis, are now available on acurrentview (delegating to the live store) and refuse with a clear error on a temporal pin — instead of being unavailable on every view.
Breaking —
find/countsignature:store.nodes.<kind>.find(...)/count(...)andstore.edges.<kind>.find(...)/count(...)now take the temporal coordinate as a second argument rather than inline in the filter object:find(filter?, temporal?)/count(filter?, temporal?). For example,nodes.Person.find({ where, temporalMode: "asOf", asOf })becomesnodes.Person.find({ where }, { temporalMode: "asOf", asOf }), andedges.worksAt.count({ temporalMode: "includeEnded" })becomesedges.worksAt.count(undefined, { temporalMode: "includeEnded" }). Old call sites that inlinedtemporalMode/asOfare now type errors.getById/getByIds/findFrom/findTo/ nodecountare unchanged (they already took a trailing temporal argument).Breaking — canonical
validFrom/validToon write:create/update/bulk*now require canonical fixed-width UTC ISO timestamps (YYYY-MM-DDTHH:mm:ss.sssZ) forvalidFrom/validTo, rejecting date-only, zoned-offset, variable/missing-millisecond, and rollover values with aValidationError. This makes the stored values that temporal filters compare as text always sort chronologically — the same contract theasOfread coordinate already enforces, applied uniformly to every timestamp in the system. Convert non-canonical inputs withnew Date(value).toISOString(). There is no migration: pre-existing non-canonical rows are left as-is (recreate them if affected) — acceptable pre-1.0.Behavior change:
store.edges.<kind>.findFrom(...)/findTo(...)/findByEndpoints(...)(and theirbatchFindFrom/batchFindTo/batchFindByEndpointsvariants) now honor the temporal model likegetById/findinstead of returning every non-soft-deleted edge. With no temporal argument, the graph’s defaulttemporalModeapplies — so under the default"current"mode, edges outside theirvalidFrom/validTowindow are now excluded. PasstemporalMode/asOfto read at another coordinate (e.g.temporalMode: "includeEnded"to recover the previous “all non-deleted” behavior).findByEndpoints/batchFindByEndpointsgain a trailingtemporal?argument and are now pinnable on aStoreView(no longer refused on a temporal pin). The internalgetOrCreate*ByEndpointsidentity lookup is unaffected — it deliberately matches against all edges regardless of validity window.Read coordinates:
asOf,.temporal("asOf", T), algorithms, subgraph, andStoreViewrequire canonical UTC ISO timestamps (YYYY-MM-DDTHH:mm:ss.sssZ) for the same lexicographic-comparison reason.
0.31.0
Section titled “0.31.0”Minor Changes
Section titled “Minor Changes”- #178
6b6e418Thanks @pdlug! - Add@nicia-ai/typegraph/graph-merge, a TypeGraph-native branch and semantic merge subpath for deterministic entity-resolution merges across graph forks.
0.30.0
Section titled “0.30.0”Minor Changes
Section titled “Minor Changes”-
#171
f5defd3Thanks @pdlug! - Addstore.nodes.<Kind>.bulkFindByIndex(indexName, items, options?)— batched candidate retrieval against declared node indexes, including non-unique ones. For each input record it returns the live nodes that share that record’s declared index key, for import reconciliation, dedup-candidate discovery, and joining records against the graph by a composite key. Each input yields its own array (candidate retrieval, not a uniqueness guarantee); buckets preserve input order and are ordered by node id.TypeGraph owns the index semantics: keys are computed from
index.fieldsonly (reusing the index’s own extraction expressions), the partialwhereis applied to stored rows, and a missing/undefinedindexed field matches a storedNULLvia a new null-safe-equality dialect adapter. An optionallimitPerInputcaps each bucket — in SQL viaROW_NUMBER()when the backend supports window functions, otherwise capped in memory with the same result. Date-typed key fields are rejected withConfigurationErrorbecause they can’t compare identically across SQLite and PostgreSQL. Unknown index names throwNodeIndexNotFoundError.createLocalSqliteBackendalso gains acapabilitiesoverride for simulating engine capability gaps (e.g.windowFunctions: false) in tests. -
#173
bd96cfbThanks @pdlug! - Add thebackend.capabilities.windowFunctionscapability and reject relevance-ranking queries before SQL generation when a custom backend profile disables SQL window functions.
0.29.0
Section titled “0.29.0”Minor Changes
Section titled “Minor Changes”-
#161
9e86269Thanks @pdlug! - Add cross-backend vector and hybrid search through a pluggableVectorStrategy, closing #157. TypeGraph now has first-class vector storage and search for libSQL/Turso, sqlite-vec, and pgvector behind the same semantic search APIs.Backend highlights:
- libSQL/Turso stores fixed-dimension embeddings in
F32_BLOB(N)columns, supports cosine/L2 search, and can use DiskANN throughlibsql_vector_idxandvector_top_k. - sqlite-vec uses
vec0KNN tables instead of brute-force vector scans. - pgvector uses graph-scoped, per-field
vector(N)tables with HNSW/IVFFlat materialization. - Backends advertise vector metrics, index types, and dimension limits from the
active strategy, and
createSqliteBackend/createPostgresBackendaccept a customvector?: VectorStrategy.
The release also adds migration and lifecycle tooling for the new storage model:
migrateLegacyEmbeddings(...)copies existing rows out of the legacy sharedtypegraph_node_embeddingstable.store.reembedVectorField(kind, fieldPath, { embed? })recreates a field’s storage after an embedding dimension change and can re-embed existing rows.store.materializeRemovals()reclaims vector tables for removed embedding fields and reports them inMaterializeRemovalsResult.reclaimedVectorFields.
Breaking storage change: vector embeddings now live in graph-scoped, fixed-dimension per-field storage instead of the shared
typegraph_node_embeddingstable. Search no longer reads the legacy table. Deployments with existing embeddings must runmigrateLegacyEmbeddings(...)once after upgrading; deployments without stored embeddings need no migration. - libSQL/Turso stores fixed-dimension embeddings in
-
#165
ae5bfdcThanks @pdlug! - ReduceBackendCapabilitiesto the flags the library actually consumes:transactions,vector, andfulltext.The descriptive-only flags
jsonb,ginIndexes,partialIndexes,cte, andreturningwere never read anywhere to gate a query feature or pick an index strategy.jsonb/ginIndexesadditionally misrepresented SQLite, which has native JSON (json_extract/json_each) and supports B-tree expression indexes on scalar JSON properties at parity with PostgreSQL — the only real JSON difference (GIN containment acceleration) is a Postgres performance characteristic, not a gated capability.If you were reading any of these removed flags, branch on
backend.dialect === "postgres"instead, or rely on the dialect layer (JSON-path predicates,WITHqueries,RETURNING, partial indexes, anddefineNodeIndex/defineEdgeIndexwork the same on both backends). -
#163
0175a25Thanks @pdlug! - Add first-class support for PGlite (Postgres-in-WASM), closing #160.- Execution fast-path fix.
createPostgresBackendnow detects a PGlitedb.$clientand routes it to the unnamed positional query wrapper. PGlite’s.queryhas no node-postgres named-statement config form — passing one desyncs its single connection (08P01), so under the defaultprepareStatements: trueevery query previously failed. PGlite works unchanged withcreatePostgresBackend(drizzle(pglite))now. createLocalPgliteBackend— a batteries-included helper under the new@nicia-ai/typegraph/postgres/pgliteentry, the Postgres analog ofcreateLocalSqliteBackend. It constructs an in-process PGlite engine (in-memory by default, or anydataDir), loads pgvector, runs the schema DDL, and returns{ backend, db, client }whoseclose()disposes the engine. Passvector: falseto skip the extension, orvector: <Extension>to bring your own pgvector build.
@electric-sql/pglite(and, for vector support,@electric-sql/pglite-pgvectoron PGlite ≥ 0.5) are optional peer dependencies. The biggest payoff: the Postgres dialect and pgvector path can now be exercised in plainpnpm testwith zero Docker. - Execution fast-path fix.
-
#162
48a6ffcThanks @pdlug! - Addvector: falsetocreatePostgresBackendto disable the vector stack.The Postgres backend wires
pgvectorStrategyby default, assuming a standalone Postgres server has the pgvector extension installed. An in-process Postgres (PGlite) built without that extension can’t honor it — the default strategy’svector(N)DDL hard-fails the moment an embedding is written orCREATE EXTENSION vectorruns. Passingvector: falseturns the stack off: the backend advertises nocapabilities.vectorand omits the embedding/search methods, mirroring a SQLite connection without sqlite-vec, so the store never routes vector work to it.Real-Postgres behavior is unchanged — the default remains
pgvectorStrategy. -
#158
bc07847Thanks @pdlug! - Export the ontology transitive-closure utilities (computeTransitiveClosure,invertClosure,isReachable) from the package root. These were previously internal-only. Exposing them lets consumers reason oversubClassOf/equivalentTohierarchies — e.g. reconciling node types when merging graphs from independent sources. -
#166
a32d31fThanks @pdlug! - Remove thetypegraph-cloudsource type from the interchangeGraphDataSourceSchema.TypeGraph Cloud is not a publicly available product, so the
typegraph-cloudvariant has been dropped from the graph-data source discriminated union, and the corresponding interchange documentation has been removed.GraphDataSourcenow accepts onlytypegraph-exportandexternal.Breaking: importing data whose
source.typeis"typegraph-cloud"now fails schema validation. Re-tag such payloads as"external"before importing. -
#165
ae5bfdcThanks @pdlug! - Support the full query feature set inside SQLite set operations (UNION/UNION ALL/INTERSECT/EXCEPT).Previously the SQLite set-operation compiler hand-rolled a thin subset of leaf compilation and rejected leaves that used traversals,
EXISTS/INsubqueries, vector or fulltext predicates,GROUP BY/HAVING, or per-leafORDER BY/LIMIT/OFFSET— throwingUnsupportedPredicateErrorat execution time. PostgreSQL accepted all of these. The result was a portability cliff: a combined query developed against PostgreSQL could throw the moment the backend was switched to SQLite.Both dialects now compile every leaf with the full query compiler and only differ in how each operand is wrapped. SQLite forbids parenthesized compound operands, but it does allow a
WITHclause inside a FROM-subquery, so each operand is emitted asSELECT * FROM (<leaf>). This keeps every leaf’s CTEs (traversal joins, recursive expansions, vector/fulltext relevance) scoped to its own subquery and lets per-leafORDER BY/LIMIT/OFFSETlive inside the wrap. Nested set operations are wrapped the same way, preserving the AST’s grouping regardless of the dialect’s native compound-operator associativity. As a side effect, vector/fulltext predicates in set-operation leaves now use the backend’s configured relevance strategy instead of falling back to the dialect default.Note:
GROUP BY/HAVINGleaves are supported at the compiler level, but the query builder still does not expose.union()/.intersect()/.except()on aggregate queries — that builder gate is unchanged and applies equally to both backends.
Patch Changes
Section titled “Patch Changes”-
#165
ae5bfdcThanks @pdlug! - FixORDER BY/LIMIT/OFFSETbeing silently dropped on a nested set-operation operand.When a set operation was nested inside another — e.g.
a.union(b).limit(10).intersect(c)— the inner compound’s suffix clauses were applied only at the top level, so the innerlimit/offsetwere ignored and the outer operation ran over the full (unlimited) inner result. The compiler now emits each nested compound’s ownORDER BY/LIMIT/OFFSETinside its operand subquery on both SQLite and PostgreSQL. -
#165
ae5bfdcThanks @pdlug! - Validate set-operation leaf vector predicates against the configured vector strategy rather than only the dialect’s fallback metric list, so a custom strategy’s metric (e.g.inner_producton SQLite) is accepted insideUNION/INTERSECT/EXCEPTleaves exactly as it is in a standalone query.Reject a per-query fulltext
languageoverride on the query-builder path (.$fulltext.matches(..., { language })) when the strategy’s tokenizer is fixed at table-create time (SQLite/FTS5), matching the store-level search guard instead of silently ignoring the option.
0.28.1
Section titled “0.28.1”Patch Changes
Section titled “Patch Changes”-
#154
6703c88Thanks @pdlug! - FixisMissingTableErrormissing DrizzleQueryError-wrapped Postgres “relation does not exist” errors, breaking fresh/partial Postgres boot (#153).isMissingTableError(the shared “relation not bootstrapped yet” discriminant forloadActiveSchemaWithBootstrap,readActiveSchemaPure, and the #135 durable-marker gate) classified failures by inspecting onlyerror.message. On Postgres, drizzle-orm wraps every query-builder call (db.select(),db.insert(), …) in aDrizzleQueryErrorwhose.messageis the failed SQL text; the real driver error — carrying bothrelation "…" does not existand SQLSTATE42P01— is preserved onerror.cause, which the helper never walked. So the helper returnedfalseand a benign “not bootstrapped yet” surfaced as a hard fault.This regressed
createStoreWithSchemaafter the #149/#152 read-only pre-check:ensureRuntimeContributionsnow callsgetMarker(a query-builder read) on the possibly-absenttypegraph_contribution_materializationstable beforeensureMarkerTable(). On Postgres that read throws aDrizzleQueryError, the helper missed it, and the open rethrew instead of materializing — breaking seed, first boot, and test global-setup on any fresh or partial Postgres database (base tables present, marker table absent — e.g. drizzle-kit-managed schemas). SQLite was unaffected because better-sqlite3 throws a raw error whose.messageliterally containsno such table.isMissingTableErrornow walks theerror.causechain (cycle-safe) and additionally keys on the locale-independent SQLSTATE42P01, rather than matching only the outermost.message. Existing message patterns are retained, so all prior matches still hold; the fix applies uniformly to all three call sites, including the latent slow-path blind spot inloadActiveSchemaWithBootstrap/readActiveSchemaPure.
0.28.0
Section titled “0.28.0”Minor Changes
Section titled “Minor Changes”-
#150
f9b1300Thanks @pdlug! - Add a per-searchefSearchknob for tuning pgvector HNSW recall (#148).store.search.vectorand the vector half ofstore.search.hybridnow accept an optionalefSearch— the HNSW search frontier (hnsw.ef_search, default 40). pgvector caps a single index scan atef_searchcandidates, so the hybrid over-fetch (vectorK = 4 * limitby default) silently under-delivers oncevectorKclimbs past the session default; the floor isefSearch >= vectorKand ~2–4× is the high-recall target. Being per-search lets one connection pool serve both a latency-sensitive interactive path and a recall-sensitive batch path.The Postgres backend applies it transaction-locally (
SET LOCAL hnsw.ef_search) around the vectorSELECT, so it never leaks to the next query on a pooled connection —SET LOCALissued in autocommit would roll off with the statement and the next pooled query would see the session default. OmittingefSearchopens no transaction and preserves today’s behavior exactly. Validated as a positive integer ≤ 1000 (pgvector’s ceiling).Scope: pgvector HNSW only. sqlite-vec has no equivalent frontier knob and treats it as a no-op; transaction-less Postgres drivers (
drizzle-orm/neon-http) ignore it with a one-time warning. IVFFlat’sivfflat.probesis a follow-up.
Patch Changes
Section titled “Patch Changes”-
#152
761c672Thanks @pdlug! - FixensureRuntimeContributionsrunning marker-table DDL on every store open (#149).createStoreWithSchema→ensureRuntimeContributionspreviously ran thetypegraph_contribution_materializationsmarker DDL (ensureMarkerTable()→CREATE TABLE IF NOT EXISTS …) on every open for any graph with runtime contributions (e.g.searchable()fields), even when every contribution was already materialized. The per-materializerinitializedGraphIdscache is per-instance, so a deployment that builds a fresh backend per request (the norm on serverless Postgres) got an empty cache each time and re-ran the DDL on every open — which intermittently fails on connections that can’t run it (observed on Cloudflare Workers + the Neon serverless driver) and surfaces as a wrappedDrizzleQueryErrorrather than a cleanMigrationError.ensureRuntimeContributionsnow does a read-only pre-check first, mirroring the SELECT-onlyassertInitialized: when every runtime contribution is already materialized (marker present, signature matches, no recorded error) it returns withoutensureMarkerTable()/materializeOne. A missing marker table, or any missing/stale/failed contribution, still falls through to the unchanged privileged first-materialization path. Warm per-request opens are now DDL-free.Note: the canonical runtime attach for the least-privilege / per-request deployment model remains
createVerifiedStore(zero DDL by construction);createStoreWithSchemaalso runs bootstrap and auto-migration DDL and is still intended to run once under a privileged role. This change is defense-in-depth for the marker DDL specifically.
0.27.0
Section titled “0.27.0”Minor Changes
Section titled “Minor Changes”-
#144
30a1cfdThanks @pdlug! - AddcreateVerifiedStoreandassertSchemaCurrent— the runtime counterparts ofcreateStoreWithSchemafor the least-privilege deployment model.createStoreWithSchema()runs DDL (bootstrap, safe auto-migrations, durable contribution materialization) and must run under a role withCREATEprivileges. For applications that want their runtime under a least-privilege, DML-only role, the previous options werecreateStore(zero-DDL attach with no schema gate — drift goes undetected until a hot-path operation trips) or hand-rolling a SELECT-only verification dance fromgetActiveSchema+getSchemaChanges.This release adds two cleanly named entrypoints that share the same zero-DDL verification path:
createVerifiedStore(graph, backend, options?)— a SELECT-only attach (zero DDL) with a verification gate. Reads the active schema row and contribution markers, folds the persisted graph extension, and refuses to construct the Store unless the database is at the same schema version as the code graph. ReturnsPromise<[Store<G>, SchemaValidationResult]>mirroringcreateStoreWithSchema. ThrowsMigrationErroron any drift (safe or breaking — the least-privilege runtime cannot migrate),ConfigurationErrorwhen no schema has been initialized, andStoreNotInitializedErrorwhen the schema is current but runtime-contribution markers (e.g. fulltext) are missing/stale.assertSchemaCurrent(backend, graph)— the same verification gate exposed as a standalone predicate for readiness probes / healthchecks. Returns theSchemaValidationResultor throws the same errors.
The recommended deployment shape is now:
- Migration step (privileged role with DDL/
CREATE): runcreateStoreWithSchema()once at startup, or applygeneratePostgresMigrationSQL/generateSqliteMigrationSQLplus a one-shotcreateStoreWithSchema()to materialize runtime contributions. - Runtime (least-privilege, DML-only role): attach with
createVerifiedStore(). Zero DDL on the runtime path; schema drift fails fast with a cleanMigrationErrorinstead of leaking into hot-path operations or 500ing on a permission error.
Internal: factored a pure
mergeStoredGraphExtensionhelper out ofloadAndMergeGraphExtensionDocumentso the SELECT-only verifier reuses the same parse + extension-merge + deprecated-kind logic without going through the bootstrap-capable loader. No behavior change for the existing schema entrypoints.Documentation: “Database roles & least privilege” in
backend-setup.mdnow folds increateVerifiedStoreas the canonical runtime attach;schema-management.mdcovers Basic / Managed / Verified stores side by side;troubleshooting.mdadds entries forMigrationErrorfrom a verifying attach andConfigurationErroron uninitialized databases.
Patch Changes
Section titled “Patch Changes”-
#144
30a1cfdThanks @pdlug! - SurfaceMigrationErrorbefore runtime-contribution DDL on a pending breaking migration (#143).loadActiveSchemaWithBootstrapranensureRuntimeContributions(fulltext contribution DDL) beforeensureSchemacomputed the schema diff and threwMigrationError. Contribution DDL is derived from the current code graph, so against a database still on the old schema version it was applied to a stale table shape. On Postgres the first failing statement aborts the surrounding transaction, and the error that escaped was the idempotent marker-tableCREATE TABLE IF NOT EXISTS "typegraph_contribution_materializations"(collateral damage), not a cleanMigrationError. Consumers using the documented migrate-on-MigrationErrorrecovery pattern never saw aMigrationError, so the first request after every breaking schema change 500’d until a concurrent boot won the migration race.loadActiveSchemaWithBootstrapno longer materializes runtime contributions.createStoreWithSchemaremains the single canonical durable-marker writer and runs the materialization step afterensureSchema, so the breaking-change gate is always reached first and a pending breaking migration throwsMigrationErroron the first request — making the migrate-then-retry recovery path work as documented. The pre-#129ensureFulltextTablefallback is preserved at the canonical writer. No API changes.
0.26.0
Section titled “0.26.0”Minor Changes
Section titled “Minor Changes”-
#139
f1ea17cThanks @pdlug! - Cross-store atomicity: share one transaction across the TypeGraph store and an external Drizzle connection (#134).Applications that persist into the same database through two layers — Drizzle for relational rows and TypeGraph for graph nodes/edges — previously had no way to make a write that spans both layers all-or-nothing.
store.transaction()anddb.transaction()each opened a separate transaction on a separate connection, so a failure between the two writes left either a stray relational row or a committed graph node with a dangling foreign reference.What ships (additive — no breaking changes):
-
New
Store.withTransaction(externalTx): TransactionContext<G>. The caller owns the transaction;store.withTransaction(sqlTx)returns a transaction-scoped{ nodes, edges }bound to that exact connection, so both layers commit or roll back together. It is driver-agnostic; how you open the transaction is not.Async drivers (node-postgres,
neon-serverlessPool, libsql):await db.transaction(async (sqlTx) => {const connector = await createConnectorRow(sqlTx, input); // Drizzleconst txStore = store.withTransaction(sqlTx);await txStore.nodes.ArtifactSource.create({// TypeGraphconnectorId: connector.id,});}); // one COMMIT / ROLLBACKSynchronous
better-sqlite3cannot usedb.transaction(async …)(its driver rejects anasynccallback); open the transaction with explicitBEGIN/COMMIT/ROLLBACKinstead and pass the connection towithTransaction. See the “Cross-Store Transactions” recipe for both shapes. -
New optional
GraphBackend.adoptTransaction(externalTx)member, implemented by the Drizzle Postgres and SQLite backends, plus the newAdoptedTransactiontype.
Guarantees. The adopted context reuses the parent store’s already-resolved schema: it runs no
createStoreWithSchema/evolve/migrateSchemaand emits no DDL inside the caller’s business transaction. Building on #135, fulltext operations assert the durable materialization marker (a cachedSELECT, never DDL) and throwStoreNotInitializedErroron a missing/stale/failed marker rather than migrating mid-transaction — so boot the parent store viacreateStoreWithSchemaonce at startup. When the backend cannot provide real rollback (backend.capabilities.transactions === false:drizzle-orm/neon-http, Cloudflare D1, SQLitetransactionMode: "none"),withTransactionthrowsConfigurationErrorrather than silently degrading — a non-atomic fallback is safe for graph-only writes but dangerous for cross-store flows, where the caller’s relational write would still commit. -
-
#142
02c98a9Thanks @pdlug! - Transactional writes for Cloudflare Durable Objects SQLite (do-sqlite) (#140).A store backed by
drizzle(ctx.storage)previously fell back to non-transactional behavior, so TypeGraph mutations could not be composed atomically with a product’s own relational ledger tables (e.g.document_versions,change_events) inside a Durable Object.What ships (additive — no breaking changes):
-
New SQLite
transactionMode: "do-sqlite", auto-detected fordrizzle(ctx.storage). Such backends now advertisecapabilities.transactions: true. -
store.transaction(async (tx) => …)and the caller-ownedstore.withTransaction(db)shape both work on Durable Objects. TypeGraph delegates to the async storage runnerctx.storage.transaction(async …)(surfaced by Drizzle asdb.$client.transaction), which rolls back SQL writes acrossawait. Drizzle’s owndb.transaction()on DO isctx.storage.transactionSyncand cannot span anawait, so it is deliberately not used. There is no Drizzle transaction handle on DO — the storage transaction is ambient on the object — so the tx-scoped backend binds the outerdb.await ctx.storage.transaction(async () => {const txStore = store.withTransaction(db);await txStore.nodes.Document.update(documentId, props);await db.insert(documentVersions).values(versionRow);await db.insert(changeEvents).values(eventRow);}); // one storage-transaction COMMIT / ROLLBACK across both layers -
A latent detection bug is fixed: drizzle’s Durable Objects session class is
SQLiteDOSession(not the previously-checkedSQLiteDurableObjectSession), so a realdrizzle(ctx.storage)store was misclassified. -
New
TransactionContext.sql— the raw Drizzle handle bound to the same transaction — for graph-owned cross-store writes across all transactional backends (Postgres, libsql, better-sqlite3, do-sqlite):await store.transaction(async (tx) => {await tx.nodes.Document.update(documentId, props);// tx.sql is the AdoptedTransaction union — cast to your concrete// Drizzle database type at the call site.const sqlTx = tx.sql as NodePgDatabase;await sqlTx.insert(documentVersions).values(versionRow);await sqlTx.insert(changeEvents).values(eventRow);});This is the graph-owned counterpart of
store.withTransaction(where the caller owns the boundary). On Postgres/libsql it is a correctness requirement — the outerdbwould write on a different connection and escape the transaction.tx.sqlisundefinedonly on the non-transactional fallback. Its static type is theAdoptedTransactionunion; cast to your concrete Drizzle database type at the call site.
Guarantees. Building on #135, no schema/bootstrap/fulltext DDL ever runs inside the business transaction:
bootstrapTablesand the durable materialization marker run outside any storage transaction, while the schema-version commit uses thedo-sqliterunner (data only). Boot the parent store viacreateStoreWithSchemaonce at object startup.Out of scope. Cloudflare D1 stays
transactionMode: "none":D1Database.batch(...)is transactional but not an interactive runner. A batch-only D1 mode is tracked separately. -
-
#138
bcf1e48Thanks @pdlug! - Durable, enforced fulltext materialization (#135).Strategy-owned fulltext table/index DDL was materialized lazily, guarded by an in-memory, per-backend-instance boolean latch (
fulltextEnsured), and interleaved into the read/write data path. That was correct only by accident (idempotent DDL + a warm process) and at the wrong durability scope; it was inconsistent with how vector indexes are tracked and it blocked cross-store transaction adoption (#134). “Is this graph’s fulltext storage materialized?” is now a durable, queryable database fact instead of a process boolean.Breaking (behavioral): fulltext now requires an explicit boot step.
createStore()is a synchronous, zero-I/O attach — it never creates tables, repairs DDL, or writes materialization markers. The durable marker is written exclusively by the async boot path,createStoreWithSchema(graph, backend), which must run once at application startup (outside request handlers and adopted transactions). A fulltext read/write — or a transaction that touches fulltext — against a database with no valid marker now throws the newStoreNotInitializedErrorinstead of lazily emitting DDL on the hot path. Consumers already usingcreateStoreWithSchemaneed no changes; consumers relying on lazy fulltext creation via barecreateStore()must add acreateStoreWithSchemacall at boot.What ships:
- New
@nicia-ai/typegraphexports:StoreNotInitializedErrorand theStoreNotInitializedReason("missing" | "stale" | "failed") it carries indetails.reason. - New per-deployment table
typegraph_contribution_materializations, a sibling oftypegraph_index_materializations(the declared-index status table is deliberately left unchanged). Keyed by #129 contribution identity(graph_id, logical_name, owner, table_name);signatureis a separate content-hash column, so a same-identity row with a drifted signature is a loud error, never a silent re-materialize. Failed re-attempts preserve the prior success timestamp via the same COALESCE rule as index materializations. - New backend primitives (SQLite + Postgres):
ensureContributionMaterializationsTable,getContributionMaterialization,recordContributionMaterialization, andassertRuntimeContributionsInitialized.ensureRuntimeContributionsandensureFulltextTablenow take agraphIdand route through the durable-marker writer (short-circuiting when the recorded signature already matches).createStoreWithSchemarecords the marker after the schema version is resolved, covering the cold-initialize path. - The six fulltext-touching methods (
upsertFulltext,deleteFulltext,upsertFulltextBatch,deleteFulltextBatch,fulltextSearch,hardDeleteNode) stop ensuring and instead assert the durable marker (resolved once per backend instance, cached). The transaction path performs zero DDL: the tx-scoped backend’s fulltext methods assert the cached marker at point of use (aSELECT, neverCREATE), so a transaction that never touches fulltext requires no fulltext initialization and one that does runs pure DML on the adopted transaction.
This makes #134 (cross-store transaction adoption) sound by construction: a transaction-adopting primitive consults the durable fact and refuses with a clear
StoreNotInitializedErrorif the store was never initialized, instead of emittingCREATE INDEXinside the caller’s business transaction. - New
-
#136
9aa2d31Thanks @pdlug! - UnifiedTableContributioncontract for strategy-owned tables (#129).“What tables does TypeGraph own?” was previously split across four uncoordinated surfaces (Drizzle named exports, tables-factory recursion, strategy raw DDL, per-table
ensureXTablemethods). Adding a new strategy- or backend-owned table without also wiring anensureXTable+ bootstrap probe re-opened the gap #128 closed. This refactor routes every owned table through one shape.Breaking (custom
FulltextStrategyimplementers only):FulltextStrategy.generateDdl(tableName): string[]is replaced byownedTables(primaryTableName): readonly StrategyTableContribution[]. A strategy now declares its tables, Drizzle-free, as already authoritative contributions (logicalName,owner, resolvedtableName, idempotentcreateDdlfor the table and its supporting indexes,runtimeEnsure). The two shipped strategies (tsvectorStrategy,fts5Strategy) and all internal callers are migrated; consumers using only the shipped strategies need no changes.What ships:
- New
@nicia-ai/typegraphexport:TableContributionandStrategyTableContribution(its strategy-declaration alias). Each contribution carries a stable, deployment-independentlogicalNameplus the resolved physicaltableName(distinct identity vs. drift-signature inputs) — the prerequisite that lets #135 make fulltext materialization a durable, decidable fact instead of an in-memory per-backend latch. postgresContributions()/sqliteContributions()are the single source of truth for DDL generation and the bootstrap ensure.generatePostgresDDL/generateSqliteDDLiterate contributions; thetable === tables.fulltextreference-identity hack is gone from DDL generation. drizzle-kit visibility for the default Postgres strategy comes from the schema barrel exporting the matchingtables.fulltextobject (one object, not two); a non-default strategy exports its own.- New backend method
ensureRuntimeContributions(), which runs eachruntimeEnsurecontribution’s full idempotentcreateDdl(table + supporting indexes) so a partial state (table present, index missing) self-heals — not a probe-and-skip.loadActiveSchemaWithBootstrapcalls it scoped toruntimeEnsurecontributions only (the strategy-owned fulltext table today), so startup does not regress into broad DDL/probing across every table.ensureFulltextTableis retained as a thin back-compat wrapper.
DDL statement ordering changes from “all CREATE TABLE, then all CREATE INDEX, then fulltext” to per-contribution “table then its own indexes”. Safe because TypeGraph’s tables carry no cross-table foreign keys; raw migration SQL byte output differs accordingly.
Prerequisite for #135 (durable fulltext materialization), which is in turn the prerequisite for #134 (cross-store transaction adoption).
- New
0.25.1
Section titled “0.25.1”Patch Changes
Section titled “Patch Changes”-
#130
dbe52dcThanks @pdlug! - Fix drizzle-kit-managed fulltext bootstrap gap on both Postgres and SQLite (#128).Consumers managing typegraph storage via
drizzle-kit push/drizzle-kit generate(export * from "@nicia-ai/typegraph/postgres"or…/sqlite") got every typegraph table EXCEPTtypegraph_node_fulltext. The fulltext table was strategy-owned raw DDL — the schema modules exposed onlyfulltextTableName: string, not a Drizzle table — so drizzle-kit silently skipped it. ThebootstrapTablesfallback inloadActiveSchemaWithBootstraponly fires on a missing-table error fromgetActiveSchema; once drizzle-kit had createdtypegraph_schema_versions, that branch stopped triggering andsearchable()writes failed at runtime withrelation/table "typegraph_node_fulltext" does not exist.Two fixes ship together:
-
backend.ensureFulltextTable()(both backends). A focused narrow-ensure that mirrors the existingensureIndexMaterializationsTable/ensureKindRemovalsTable/ensureReconciliationMarkersTableidiom — single-tableCREATE … IF NOT EXISTS, no Postgres SHARE-lock deadlock under concurrent replica startup. The backend wraps every method that emits fulltext SQL (upsertFulltext/deleteFulltextand their batch variants,fulltextSearch, andhardDeleteNodewhose cascade unconditionally deletes from the fulltext table) to call the ensure first. A per-backend latch makes the per-call cost a single boolean check after the first invocation, so the wrapping is safe on the hot path.loadActiveSchemaWithBootstrapalso calls the ensure as a belt-and-suspenders for thecreateStoreWithSchemapath. Together these cover both async schema-aware boot AND the synccreateStorepath — the bare bootstrap-load probe alone would miss the latter. This is the canonical fix and the only viable one for SQLite (FTS5 virtual tables aren’t drizzle-kit-modelable). -
Typed Drizzle pg-core table for
tsvectorStrategy(Postgres only).createPostgresTables()now returnstables.fulltext— a typedpgTablefor the defaulttsvector+ GIN stack — alongsidetables.fulltextTableName. The newfulltextnamed export is included in@nicia-ai/typegraph/postgres, soexport *lets drizzle-kit generate migrations for the fulltext table the same way it does fornodes/edges/etc. Customtsvector/regconfigcolumn types are exported alongside the existingvectorcolumn.`generatePostgresDDL` deliberately skips the typed Drizzle table(the column-walker can't reproduce the `GENERATED ALWAYS AS (…)STORED`clause) and continues to defer totsvectorStrategy.generateDdl()for the runtime DDL emit. The two paths agree byte-for-byte; a drift sentinel test catches any divergence.Alternate Postgres fulltext strategies (pg_trgm, ParadeDB,pgroonga) still own their own DDL via`FulltextStrategy.generateDdl()` and the bootstrap probe runs it.Drizzle-kit consumers using a non-default strategy must override`tables.fulltext` in their schema barrel with their strategy'sown table.
Documented the SQLite FTS5 virtual-table caveat and the new Postgres
tables.fulltextexport inapps/docs/src/content/docs/integration.md. -
0.25.0
Section titled “0.25.0”Minor Changes
Section titled “Minor Changes”0.25.0 is the runtime schema evolution release. It adds graph extensions, unified index declarations and materialization, dynamic queries over runtime-declared kinds, runtime access to compiled props schemas, and a safer transactional schema-version commit path.
Highlights
Section titled “Highlights”- Graph extensions let applications commit reviewed JSON schema proposals as durable TypeGraph schema versions without redeploying application code.
- Compile-time, graph-extension, relational, and vector indexes now share one
canonical declaration channel and flow through
Store.materializeIndexes(). - Dynamic query builder methods let typed queries traverse runtime-declared node and edge kinds while still validating kind names, endpoints, and field predicates at query-build time.
Storenow exposes compiled Zod props schemas for compile-time and graph-extension kinds throughgetNodePropsSchema,getEdgePropsSchema, and theirOrThrowvariants.- Node and edge definitions now accept JSON-serializable
annotationsfor consumer-owned metadata such as UI hints, audit policy, and provenance.
New APIs
Section titled “New APIs”defineGraphExtension(input)andvalidateGraphExtension(input, options?).Store.evolve,Store.deprecateKinds,Store.undeprecateKinds,Store.removeKinds,Store.materializeRemovals, and dynamic collection accessors for graph-extension kinds.defineGraph({ indexes }),defineNodeIndex,defineEdgeIndex,andWhere,orWhere,notWhere, and the@nicia-ai/typegraph/indexessubpath for advanced index tooling.Store.materializeIndexes(options?)plusMaterializeIndexesResultstatus reporting.embedding(dimensions, options?)vector index options and exported vector index declaration/configuration types.fromDynamic,traverseDynamic,optionalTraverseDynamic, andtoDynamicon the query builder.SchemaValidationResult.initializedand.migratednow includecommittedRow: SchemaVersionRow.SqlTableNamesnow includesuniquesso cleanup paths can honor custom physical table names.
Performance and reliability
Section titled “Performance and reliability”- Schema commits now use a transactional
commitSchemaVersionbackend primitive instead of the old insert-then-activate sequence, fixing the orphan schema-row crash window. materializeIndexesbulk-loads materialization status in one round trip and records per-index drift/failure state intypegraph_index_materializations.materializeRemovalsrecords a reconciliation watermark, honors custom table names, and cleans secondary embedding/fulltext/unique rows for removed node kinds.- Schema hash and parsed-schema caches avoid repeated serialization, SHA-256, and Zod parse work on no-change startup and repeated store creation.
- Graph-extension merge/compile paths share caches and fast paths for idempotent or partially overlapping evolves.
- Postgres vector-index drops now run per-metric DDL concurrently.
Breaking changes for backend implementers
Section titled “Breaking changes for backend implementers”These changes affect custom GraphBackend implementations and advanced index
consumers; ordinary createStoreWithSchema, query, and collection callers
should not need code changes.
insertSchemaandsetActiveSchemawere removed fromGraphBackend. ImplementcommitSchemaVersionandsetActiveVersioninstead.commitSchemaVersionandsetActiveVersionrequire transactional behavior. Non-transactional drivers such as Cloudflare D1, Durable Objects,drizzle-orm/neon-http, and SQLite backends configured withtransactionMode: "none"refuse these primitives for schema commits.createFulltextIndexanddropFulltextIndexwere removed fromGraphBackend; fulltext storage remains owned by the active backend fulltext strategy.- The old
NodeIndex,EdgeIndex, andTypeGraphIndextypes were removed from@nicia-ai/typegraph/indexes. UseNodeIndexDeclaration,EdgeIndexDeclaration, orIndexDeclaration. - Custom backends should add the new optional materialization/removal primitives when they want first-class support for index status loading, removal reconciliation markers, and vector index materialization.
Upgrade notes
Section titled “Upgrade notes”- Existing deployments with manually managed schemas should add the one-active
schema-version partial unique index:
typegraph_schema_versions_one_active_per_graph_idxon(graph_id)whereis_activeis true (TRUEon Postgres,1on SQLite). - Manually managed schemas should also sync the generated DDL for the new
TypeGraph status tables, including
typegraph_index_materializations,typegraph_kind_removals, andtypegraph_reconciliation_markers. - Run schema migrations from a transactional backend. Edge or HTTP-only non-transactional drivers can continue serving normal reads and writes after the schema is established.
- Tests that deep-compare the full
SchemaValidationResultobject may need to switch to partial matching becauseinitializedandmigratednow includecommittedRow.
Pull requests
Section titled “Pull requests”- #103 - Add per-kind
annotations. - #106 - Add atomic schema version commits.
- #107 - Add compile-time index declarations to graph definitions and serialized schemas.
- #112 - Add
Store.materializeIndexes. - #117 - Unify vector indexes with the index declaration channel.
- #118 - Add graph extensions.
- #125 - Add dynamic query traversal methods.
- #126 - Expose runtime Zod props schemas.
- #127 - Pre-release cleanup and performance pass.
0.24.1
Section titled “0.24.1”Patch Changes
Section titled “Patch Changes”-
#99
755df5aThanks @pdlug! - Internal: dependency bump pass (patch/minor only — TypeScript and@types/nodeheld back as separate majors).Notable runtime/peer-relevant moves:
nanoid5.1.9 → 5.1.11 (only published runtime dep); dev/peerzod4.3.6 → 4.4.3,@libsql/client0.17.2 → 0.17.3.Also drops the
exportkeyword on 14 types that were never reachable through any public entry point (src/index.ts,./schema,./indexes,./sqlite,./postgres, etc.) and had no internal importers. These were leaked-internal types surfaced by a sensitivity change inknip6.11. No symbol on the documented API surface changed; consumers importing only via the package’s declaredexportspaths are unaffected.
0.24.0
Section titled “0.24.0”Minor Changes
Section titled “Minor Changes”-
#97
8747df8Thanks @pdlug! - SQLite: implementbackend.vectorSearch, unblockingstore.search.hybrid()on SQLite.The hybrid retrieval facade has been Postgres-only since #88: SQLite shipped fulltext (
fulltextSearch) and embedding persistence (upsertEmbedding/deleteEmbedding), but never thevectorSearchmethod thatexecuteHybridSearchrequires for RRF fusion..similarTo()on SQLite still worked because the predicate path goes through the query compiler, not the backend facade — but anyone reaching forstore.search.hybrid()on SQLite hitConfigurationError: Backend does not support vector search.This release wires up the SQLite half of that contract:
buildVectorSearchSqliteissuesvec_distance_cosine/vec_distance_l2against the embeddings BLOB column, mirroring the Postgres SQL shape (same WHERE / ORDER BY / score expression / minScore semantics).createSqliteBackendexposesvectorSearchon the backend object wheneverhasVectorEmbeddingsis true (parallel to the existingupsertEmbeddinggate).inner_productis rejected — sqlite-vec has novec_distance_ipfunction.
import { createLocalSqliteBackend } from "@nicia-ai/typegraph/sqlite/local";const { backend } = createLocalSqliteBackend(); // sqlite-vec auto-loadedconst store = createStore(graph, backend);const ranked = await store.search.hybrid("Document", {limit: 10,vector: { fieldPath: "embedding", queryEmbedding },fulltext: { query: "climate adaptation" },});Performance. On the standard search-shapes bench (500 docs, 384-dim), SQLite hybrid clocks in at 0.8ms — about 3× faster than PostgreSQL’s 2.5ms on the same shape. The bench harness now measures it on both backends; the previously-blank SQLite cell in the search comparison table is filled in.
0.23.0
Section titled “0.23.0”Minor Changes
Section titled “Minor Changes”-
#95
6f3bf30Thanks @pdlug! - PostgreSQL: official postgres-js / Neon support, server-side prepared statements on the fast path, and arefreshStatistics()API.Four drivers supported.
createPostgresBackendhas always been driver-agnostic, but onlynode-postgreswas covered in CI. This release adds:drizzle-orm/postgres-js— full adapter + integration suite coverage (~250 tests run against bothpgandpostgres-jsagainst a real PostgreSQL).drizzle-orm/neon-serverless—@neondatabase/serverlessPool over WebSockets. Wiring smoke tests verify driver detection, fast-path routing, Date→string normalization, and capability surface; the shared code paths are exercised by thepgintegration suite since this driver is pg-Pool-protocol-compatible.drizzle-orm/neon-http—@neondatabase/serverlessneon(url)over HTTP. Auto-detected socapabilities.transactionsis set tofalse(HTTP can’t hold a session); single-statement reads, writes, and migrations work normally. Smoke tests verify the detection and capability override.
Same
createPostgresBackend(db)entry point regardless of driver.// postgres-jsimport postgres from "postgres";import { drizzle } from "drizzle-orm/postgres-js";const backend = createPostgresBackend(drizzle(postgres(process.env.DATABASE_URL)),);// Neon serverless (edge runtimes)import { Pool } from "@neondatabase/serverless";import { drizzle } from "drizzle-orm/neon-serverless";const backend = createPostgresBackend(drizzle(new Pool({ connectionString: env.NEON_DATABASE_URL })),);On Neon HTTP vs WebSockets: both work. The HTTP driver (
drizzle-orm/neon-http) is best for stateless edge workloads — TypeGraph auto-disables transactions since HTTP can’t hold a session, andstore.transaction(...)falls through to non-transactional sequential execution. Use the WebSocket driver (drizzle-orm/neon-serverless) when you need atomic multi-statement writes.~6× faster on multi-hop traversals via server-side prepared statements. The execution adapter now uses
node-postgres’s named prepared statements transparently — each unique compiled SQL string gets a stable counter-derived statement name (cached by SQL text), so PostgreSQL caches the plan after first execution. Combined with routingexecute()through the fast path directly (skipping Drizzle’s session wrapper), this drops the 3-hop benchmark from ~7.5ms to ~0.8ms median, putting TypeGraph-on-PostgreSQL at parity with Neo4j on every single-query and multi-hop shape we measure.The change is invisible to callers; existing code keeps working. postgres-js is unchanged (it handles its own preparation internally).
New
store.refreshStatistics()/backend.refreshStatistics()API. Call once after a large initial import or bulk backfill. Without fresh stats, the planner can pick suboptimal execution plans — on PostgreSQL this is the difference between a 0.5ms and 5ms forward traversal; on SQLite it’s the difference between 0.9ms and 23ms fulltext search. Autovacuum / background statistics catch up eventually, but explicit invocation gives correct latencies immediately.for (const batch of batches) {await store.nodes.Document.bulkCreate(batch);}await store.refreshStatistics();Implementations: SQLite runs
ANALYZE; PostgreSQL runsANALYZEon TypeGraph-managed tables only. Costs ~20ms on SQLite, ~80ms on PostgreSQL at the sizes this library is designed for.Type surface changes:
GraphBackendnow requires arefreshStatistics(): Promise<void>method.TransactionBackendstill excludes it (statistics refresh isn’t meaningful inside a transaction). ExternalGraphBackendimplementations (uncommon) need to add a no-op or proper implementation.PostgresBackendOptionsadds an optionalcapabilities?: Partial<BackendCapabilities>for users who need to override capability flags (e.g., for custom HTTP-style drivers).PostgresBackendOptionsalso addsprepareStatements?: boolean(defaulttrue) andpreparedStatementCacheMax?: number(default256). The prepared-statement name cache is now LRU-bounded so high-cardinality SQL text doesn’t grow unbounded in either the Node process or in PostgreSQL’s per-session prepared-statement memory. SetprepareStatements: falsewhen pooling through pgbouncer in transaction-pool mode.
See
backend-setupfor the runtime-to-driver matrix, per-driver setup snippets, and post-bulk-load guidance.
0.22.0
Section titled “0.22.0”Minor Changes
Section titled “Minor Changes”-
#93
1e9ae18Thanks @pdlug! - AddcountEdges(edgeAlias)andcountDistinctEdges(edgeAlias)— edge-count aggregators that skip the target-node join in the count aggregate fast path.The default
count(targetAlias)counts edges whose target node is currently live under the query’s temporal mode, which requires joining the edges to the target node table on every aggregation. For the common “how many follow relationships does this user have?” question, that join is unnecessary work: you want to count edges, not reach through each edge to validate the target.import { count, countEdges, field } from "@nicia-ai/typegraph";const result = await store.query().from("User", "u").optionalTraverse("follows", "e", { expand: "none" }).to("User", "target").groupByNode("u").aggregate({name: field("u", "name"),// Counts live edges, regardless of target-node validity.// Skips the typegraph_nodes join entirely — ~1.7x faster on// SQLite, ~1.35x on PostgreSQL at benchmark scale.followCount: countEdges("e"),// Counts edges to live targets. Keeps the target-node join// so the target's temporal window is honored.liveFollowCount: count("target"),}).execute();When to use which:
count(targetAlias)— when the semantic question is “how many of this user’s follows point to a live user?” The target-node join enforces the target’svalidTo/deleted_atfilters.countEdges(edgeAlias)— when the semantic question is “how many follow relationships does this user have?” The edge’s own temporal and deletion filters are enforced; target validity is not consulted.countDistinctEdges(edgeAlias)— same semantics ascountEdgesbut withCOUNT(DISTINCT ...). Useful under ontology-driven expansions where the same edge can appear multiple times in join output.
The two can be mixed in one aggregate. When present together, the compiler keeps the target-node join but switches it to a
LEFT JOINwith node-side filters pushed into theONclause so edge counts reflect all live edges while node counts only reflect edges to live targets.No change to existing
count(...)behavior. This is purely additive — code that currently usescount("targetAlias")continues to count live targets exactly as before.
Patch Changes
Section titled “Patch Changes”-
#93
1e9ae18Thanks @pdlug! - PushLIMITpastGROUP BYin the count aggregate fast path when it’s safe.When
groupByNode(...).aggregate({ x: count(alias) })is paired with an optional traversal and a.limit(n)that doesn’t depend on the aggregate (noORDER BY, or anORDER BYrestricted to group keys), the compiler now emits theLIMITinside the start CTE. TheGROUP BYruns overnrows instead of the full start set —O(limit)grouping work instead ofO(|start|). WhenOFFSETis also set, it rides along with theLIMITinto the start CTE and the outerSELECTdrops its ownLIMIT/OFFSETso neither clause is double-applied.The fast path also picks
INNER JOINoverLEFT JOINfor the target-node join whenever awhereNode()predicate applies to the target alias, so those predicates constrain every aggregate — includingcountEdges(...).LEFT JOINremains the strategy when only temporal/delete filters apply to the target, socountEdgesandcount(target)can coexist in one query with divergent semantics.No change to query semantics — aggregate counts still reflect the same
count(target)as before, including the target node’s temporal and deletion filters. No change to aggregate queries without aLIMIT. No change on SQLite or PostgreSQL query shapes outside the fast path.Measured impact: scopes down group-by work for “top-N by count”-style aggregate queries. No impact on the blog-post benchmark’s full-graph aggregate (which measures the ungrouped 1,200-user case and intentionally runs without a
LIMIT). -
#93
1e9ae18Thanks @pdlug! - FixgenerateSqliteDDLandgeneratePostgresMigrationSQLemitting(unknown, unknown, ...)for indexes threaded throughcreateSqliteTables({}, { indexes })orcreatePostgresTables({}, { indexes }).The DDL generator’s SQL-chunk flattener didn’t handle two cases that appear inside index expression keys: Drizzle column references nested inside a SQL stream (whose
.getSQL()wraps the column back inside a self-referential SQL object, causing the previous logic to recurse and fall through to"unknown"), andStringChunkvalues stored as single-element arrays ([""]).Expression indexes now emit correctly in both dialects, e.g.
CREATE INDEX IF NOT EXISTS "idx_tg_node_user_city_cov_name_…" ON "typegraph_nodes"("graph_id", "kind", (json_extract("props", '$."city"')), (json_extract("props", '$."name"')));Added a regression test in
tests/indexes.test.tsasserting that DDL fromcreateSqliteTables/createPostgresTablesnever contains(unknownand includes the expected column andjson_extract/ARRAY['…']expressions. -
#93
1e9ae18Thanks @pdlug! - EmitNOT MATERIALIZEDon PostgreSQL traversal and start CTEs so the planner can inline them and see their inner row statistics.PostgreSQL defaults to materializing any CTE referenced more than once. TypeGraph’s traversal compilation references each CTE twice — once from the next hop’s join, once from the final SELECT — which triggers materialization under the default rules. Materialized CTEs have opaque statistics to the planner, causing poor join orderings and wildly off row estimates on multi-hop queries over larger graphs.
Introduces a
emitNotMaterializedHintdialect capability (truefor PostgreSQL,falsefor SQLite, which ignores the hint entirely) and threads it through the start-CTE and traversal-CTE emitters. The hint matches what an expert would write by hand for the same query shape.Impact on the TypeGraph benchmark suite:
- Multi-hop traversal plans no longer carry opaque materializations, so the planner picks index-scan orderings appropriate to the starting row’s selectivity.
- No visible change on SQLite (the hint is not emitted).
- Guards against regressions on larger graphs where materialized CTE plans degenerate into cross-product-plus-filter.
-
#93
1e9ae18Thanks @pdlug! - Persist vector embeddings on the SQLite backend when sqlite-vec is loaded.Previously,
store.nodes.X.create({ ..., embedding: [...] })on SQLite validated the embedding and inserted the node, but the embedding itself was silently dropped — the SQLite backend didn’t implementupsertEmbedding/deleteEmbedding, so the store’s embedding-sync path quietly no-op’d. Vector predicates liked.embedding.similarTo(q, 20, { metric: "cosine" })then ran against an emptytypegraph_node_embeddingstable and returned zero rows without error.This release wires up both methods on the SQLite backend. They encode embeddings to
vec_f32('[...]')BLOBs on write and rely on sqlite-vec at query time — same storage shape the existing.similarTo()compilation already targets. Activation is opt-in via a newhasVectorEmbeddingsoption oncreateSqliteBackendso callers that haven’t loaded sqlite-vec don’t hitno such function: vec_f32at write time.createLocalSqliteBackendbest-effort-loads sqlite-vec at startup and flips the option automatically, so the common local setup works without configuration.// Local backend: sqlite-vec is loaded automatically when installed.const { backend } = createLocalSqliteBackend();// BYO drizzle connection: pass hasVectorEmbeddings after loading sqlite-vec.import sqliteVec from "sqlite-vec";sqliteVec.load(sqlite);const backend = createSqliteBackend(drizzle(sqlite), {tables,hasVectorEmbeddings: true,});getEmbeddingand the hybrid-search facade (store.search.hybrid(...)) remain PostgreSQL-only — decoding the raw BLOB back tonumber[]viavec_to_jsonand exposing a hybrid-search backend method are tracked separately.
0.21.0
Section titled “0.21.0”Minor Changes
Section titled “Minor Changes”-
#88
6f681d5Thanks @pdlug! - Add fulltext search and hybrid (vector + fulltext) retrieval. Declaresearchable()string fields on any node schema and TypeGraph keeps a native FTS index in sync —tsvector+ GIN on PostgreSQL, FTS5 on SQLite. Query it through a node-leveln.$fulltext.matches()predicate that composes with metadata filters, graph traversal, and vector similarity in one SQL statement.import { defineNode, searchable, embedding } from "@nicia-ai/typegraph";const Document = defineNode("Document", {schema: z.object({title: searchable({ language: "english" }),body: searchable({ language: "english" }),tenantId: z.string(),embedding: embedding(1536),}),});// Fulltext + metadata filter in a single queryconst results = await store.query().from("Document", "d").whereNode("d", (d) =>d.$fulltext.matches("climate change", 20).and(d.tenantId.eq(tenant)),).select((ctx) => ctx.d).execute();// Hybrid: vector + fulltext fused with Reciprocal Rank Fusion at the SQL layerconst hybrid = await store.query().from("Document", "d").whereNode("d", (d) =>d.$fulltext.matches("climate", 50).and(d.embedding.similarTo(queryVector, 50)).and(d.tenantId.eq(tenant)),).select((ctx) => ctx.d).limit(10).execute();// Store-level helper with tunable RRF weights and snippetsconst tuned = await store.search.hybrid("Document", {limit: 10,vector: { fieldPath: "embedding", queryEmbedding: queryVector },fulltext: { query: "climate change", includeSnippets: true },fusion: { method: "rrf", k: 60, weights: { vector: 1, fulltext: 1.5 } },});Query modes cover
websearch(Google-style syntax — default),phrase,plain, andraw(dialect-native tsquery / FTS5 MATCH). Highlighting viats_headline/snippet()is opt-in per query. No extensions required: Postgres uses the built-intsvector+ GIN (works on every managed provider); SQLite uses FTS5 which is statically linked into the standardbetter-sqlite3/libsql/bun:sqlitedistributions. See/fulltext-searchfor the full guide.n.$fulltext— node-level fulltext accessor;.matches(query, k?, options?)composes against the combinedsearchable()content.$fulltextis exposed on everyNodeAccessor; a runtime guard throws a clear error if the node kind has nosearchable()fields.kdefaults to 50.store.searchfacade —store.search.fulltext(),store.search.hybrid(), andstore.search.rebuildFulltext()grouped under one namespace. Lazy-initialized and cached on first access.FulltextSearchHit,VectorSearchHit, andHybridSearchHitare generic over the node type (FulltextSearchHit<N = Node>).store.search.fulltext("Document", ...)returns hits withhit.nodenarrowed to the Document node shape — no cast required.backend.upsertFulltextBatch+backend.deleteFulltextBatch— symmetric batched fulltext primitives. Homogeneous batch shape, duplicate-nodeId dedupe last-write-wins, per-row fallback when unset.store.search.rebuildFulltext(nodeKind?, { pageSize?, maxSkippedIds? })— rebuilds the fulltext index from existing node data using keyset pagination onid(stable under shared timestamps and light concurrent writes). Transacts per page; cleans stale rows for soft-deleted nodes; validatespageSizeas a positive integer; counts corrupt / non-object props asskippedand surfaces offending IDs viaskippedIdswithout aborting.maxSkippedIds(default 10,000) lets operators investigating systemic corruption collect the full list. Concurrent hard-deletes between pages may be missed — document as maintenance operation.- Keyset pagination on
findNodesByKindvia new{ orderBy, after }params. QueryBuilder.fuseWith({ k?, weights? })— tunable RRF on the query-builder path. FlatHybridFusionOptionsshape, identical tostore.search.hybrid’sfusionoption. Throws at compile time if the query lacks either a.similarTo()orn.$fulltext.matches(). Shares its validator withstore.search.hybrid({ fusion })somethod,k, and per-source weights are checked identically on both paths.FulltextStrategy— pluggable abstraction (exported from the top-level entry) that owns the entire SQL pipeline for a dialect’s fulltext support: DDL, upsert (single + batch), delete (single + batch), MATCH condition, rank expression, and snippet expression. ShipstsvectorStrategy(Postgres built-intsvector) andfts5Strategy(SQLite FTS5); dialect adapters exposefulltext: FulltextStrategy | undefined. Alternate Postgres stacks (pg_trgm, ParadeDB / pg_search, pgroonga) choose their own column layout, index type, and projection — TypeGraph’s operation layer just delegates to the active strategy. Strategies declare prefix-query support explicitly viaFulltextStrategy.supportsPrefix, so capability discovery stays correct for strategies that support prefix matching via dedicated syntax without advertising raw-mode pass-through.- Backend-level fulltext strategy override:
createPostgresBackend(db, { fulltext })andcreateSqliteBackend(db, { fulltext })accept aFulltextStrategythat takes precedence over the dialect default. Threaded through to compiler passes, backend-direct search SQL, all write SQL, DDL generation, and capability discovery — so a ParadeDB-backed Postgresstore.search.hybrid()fuses the same way a tsvector-backed one does, without any call-site changes. - Option validation:
store.search.fulltextandstore.search.hybridvalidate caller options against the activeFulltextStrategy(falling back toBackendCapabilities.fulltext.{phraseQueries, highlighting, languages}when no strategy is attached). Amodeoutsidestrategy.supportedModesthrows,includeSnippets: trueon a strategy whosesupportsSnippetsis false throws, and a per-querylanguageoverride on a strategy whosesupportsLanguageOverrideis false (e.g. SQLite FTS5) throws. Advisory warning for unknown languages on strategies that honor overrides.$fulltext.matches()is validated against the dialect strategy’ssupportedModesat compile time. - One-time
console.warnwhen a node kind has multiplesearchable()fields with conflictinglanguagevalues. The first field’s language wins on the stored row; the warning makes the silent collapse visible so users know to split multilingual content across dedicated node kinds. - Snippet highlighting uses
<mark>…</mark>consistently across both shipped strategies (ts_headlineon Postgres,snippet()on SQLite). One stylesheet applies everywhere. FulltextSearchResult.scoreis alwaysnumber. The Postgres adapter coercesnumeric-as-string driver returns at the backend boundary so downstream code never sees a union type.- Hybrid SQL emitter uses a deterministic
COALESCE(fulltext.node_id, embeddings.node_id) ASCtiebreak, matching the JS-sidelocaleCompare(nodeId)tiebreak used bystore.search.hybrid— both hybrid paths produce identical top-k under RRF score ties. - Postgres fulltext table schema:
languageisregconfig(notTEXT) andtsvis aGENERATED ALWAYS AS (to_tsvector("language", "content")) STOREDcolumn. Postgres owns thecontent / language → tsvinvariant; the strategy’s write SQL doesn’t recomputetsvinline. Thecontentcolumn is populated verbatim, and the per-querylanguageoverride path still accepts a text parameter (cast toregconfigat query time). SQLite’s FTS5 virtual table is unchanged.
Changed
Section titled “Changed”defineNode()/defineEdge()reject$-prefixed property names. The$namespace is reserved for node-level accessors (starting with$fulltext). AConfigurationErroris raised at graph-definition time instead of silently shadowing user fields at query time. Rename any such fields before upgrading.findNodesByKindoffset pagination now has a deterministic tiebreaker (ORDER BY created_at DESC, id DESC). Row order was previously under-specified whencreated_atvalues collided; callers that happened to rely on an implementation-dependent order may see different tie-breaking.
0.20.0
Section titled “0.20.0”Minor Changes
Section titled “Minor Changes”-
#85
12055d0Thanks @pdlug! - Add Tier 1 graph algorithms onstore.algorithms.*:shortestPath,reachable,canReach,neighbors, anddegree.// Find the shortest path through a set of edge kindsconst path = await store.algorithms.shortestPath(alice, bob, {edges: ["knows"],maxHops: 6,});// Enumerate reachable nodes within a depth boundconst reachable = await store.algorithms.reachable(alice, {edges: ["knows"],maxHops: 3,});// Fast existence checkconst connected = await store.algorithms.canReach(alice, bob, {edges: ["knows"],});// k-hop neighborhood (source always excluded)const twoHop = await store.algorithms.neighbors(alice, {edges: ["knows"],depth: 2,});// Count incident edgesconst total = await store.algorithms.degree(alice, { edges: ["knows"] });All traversal algorithms compile to a single recursive-CTE query and share the dialect primitives used by
.recursive()andstore.subgraph(), so SQLite and PostgreSQL yield identical semantics. Node arguments accept either a raw ID string or any object with anidfield —Node,NodeRef, and the lightweight records returned by the algorithms themselves all work. See/graph-algorithmsfor the full reference. -
#85
12055d0Thanks @pdlug! - Graph algorithms (store.algorithms.*) andstore.subgraph()now honor the store’s temporal model.New: Every algorithm and
store.subgraph()accepttemporalModeandasOfoptions, matching the shape already used bystore.query()and collection reads. When neither is supplied, the resolved mode falls back tograph.defaults.temporalMode(typically"current").// Snapshot at a point in timeawait store.algorithms.shortestPath(alice, bob, {edges: ["knows"],temporalMode: "asOf",asOf: "2023-01-15T00:00:00.000Z",});await store.subgraph(rootId, {edges: ["has_task"],temporalMode: "includeEnded",});The filter applies to both nodes and edges along the traversal, is orthogonal to
cyclePolicy, and is honored by the shortest-path self-path short-circuit.BREAKING:
store.subgraph()previously ignored graph temporal settings and filtered only bydeleted_at IS NULL(equivalent to"includeEnded"). It now defaults tograph.defaults.temporalMode. Callers that relied on walking through validity-ended rows must passtemporalMode: "includeEnded"explicitly. Soft-delete filtering is unchanged under the default"current"mode, so most callers see no difference.
Patch Changes
Section titled “Patch Changes”-
#87
f52bba6Thanks @pdlug! - Fix SQLite temporal filter timestamp format in graph algorithms and subgraph.buildReachableCte,resolveTemporalFilter, andfetchSubgraphEdgescompiled temporal filters without passingdialect.currentTimestamp(), so on SQLite they fell back to rawCURRENT_TIMESTAMP(YYYY-MM-DD HH:MM:SS). Storedvalid_from/valid_touse ISO-8601 (YYYY-MM-DDTHH:MM:SS.sssZ), and becauseTsorts above space, same-day ISO timestamps compare incorrectly against rawCURRENT_TIMESTAMP. UndertemporalMode: "current"this causedreachable/canReach/neighbors/shortestPath/degreeand thesubgraphedge hydration to misclassify rows whosevalid_fromorvalid_tofell on today’s date, disagreeing withstore.query()and collection reads.All three call sites now inject the dialect-specific current timestamp (
strftime('%Y-%m-%dT%H:%M:%fZ','now')on SQLite,NOW()on PostgreSQL), matching the query compiler.
0.19.0
Section titled “0.19.0”Minor Changes
Section titled “Minor Changes”-
#83
206f464Thanks @pdlug! - BREAKING:store.subgraph()now returns an indexed result instead of flat arrays.The result shape changes from
{ nodes: Node[], edges: Edge[] }to:{root: Node | undefined;nodes: ReadonlyMap<string, Node>;adjacency: ReadonlyMap<string, ReadonlyMap<EdgeKind, Edge[]>>;reverseAdjacency: ReadonlyMap<string, ReadonlyMap<EdgeKind, Edge[]>>;}This eliminates the indexing boilerplate every consumer had to write before traversing the subgraph. Nodes are keyed by ID for O(1) lookup, and edges are organized into forward/reverse adjacency maps keyed by
nodeId → edgeKind.Migration:
result.nodesis now aMap— use.sizeinstead of.length,.values()instead of direct iteration,.has(id)/.get(id)instead of.find()result.edgesis removed — access edges viaresult.adjacency.get(fromId)?.get(edgeKind)orresult.reverseAdjacency.get(toId)?.get(edgeKind)result.rootprovides the root node directly (no lookup needed)
0.18.0
Section titled “0.18.0”Minor Changes
Section titled “Minor Changes”-
#80
0845fa9Thanks @pdlug! - Add first-class libsql backend at@nicia-ai/typegraph/sqlite/libsqlNew convenience export
Section titled “New convenience export”createLibsqlBackend(client, options?)wraps@libsql/clientwith automatic DDL execution and correct async execution profile. The caller retains ownership of the client, enabling shared-driver setups. Works with local files, in-memory databases, and remote Turso URLs.import { createClient } from "@libsql/client";import { createLibsqlBackend } from "@nicia-ai/typegraph/sqlite/libsql";const client = createClient({ url: "file:app.db" });const { backend, db } = await createLibsqlBackend(client);const store = createStore(graph, backend);Bug fixes for async SQLite drivers
Section titled “Bug fixes for async SQLite drivers”db.get()crash on empty results — switched todb.all()[0]to work around Drizzle’snormalizeRowcrash when libsql returns no rows (drizzle-team/drizzle-orm#1049)instanceof Promisecheck fails for Drizzle thenables — all SQLite exec helpers now use unconditionalawaitsince Drizzle returnsSQLiteRawobjects that are thenable but notPromiseinstances (drizzle-team/drizzle-orm#2275)
Internal improvements
Section titled “Internal improvements”- Extracted
wrapWithManagedClose()helper for idempotent backend close with teardown - Shared adapter and integration test suites now accept async backend factories
- libsql backend runs the full shared test suite (214 tests)
0.17.0
Section titled “0.17.0”Minor Changes
Section titled “Minor Changes”-
#77
b9fc057Thanks @pdlug! - feat: support orderBy on edge properties in query builderThe
orderBymethod now accepts edge aliases in addition to node aliases, allowing results to be ordered by properties on traversed edges. This eliminates the need to denormalize ordering fields onto nodes or sort in memory.store.query().from("Person", "p").traverse("worksAt", "e").to("Company", "c").orderBy("e", "salary", "asc") // order by edge property.select((ctx) => ({ name: ctx.p.name, salary: ctx.e.salary })).execute();Also fixes CTE alias resolution for edge aliases in
groupByand vector order-by compilation paths.Closes #76
0.16.2
Section titled “0.16.2”Patch Changes
Section titled “Patch Changes”-
#73
1c95d8eThanks @pdlug! - fix: dispose serialized execution queue on backend close to prevent unhandled rejectionsWhen the SQLite backend’s underlying database is destroyed while operations are still queued (e.g., during Cloudflare Workers test teardown), the serialized execution queue now properly disposes pending promises. Calling
backend.close()signals the queue to suppress errors from in-flight tasks and reject new operations withBackendDisposedError.Fixes #72
0.16.1
Section titled “0.16.1”Patch Changes
Section titled “Patch Changes”- #70
cebf681Thanks @pdlug! - Widen ID parameters onDynamicNodeCollectionandDynamicEdgeCollectionto accept plainstringinstead of brandedNodeId/EdgeIdtypes, removing the need for casts when using the dynamic collection API with IDs from edge metadata, snapshots, or external input.
0.16.0
Section titled “0.16.0”Minor Changes
Section titled “Minor Changes”- #66
2f241a9Thanks @pdlug! - Addstore.getNodeCollection(kind)andstore.getEdgeCollection(kind)methods for runtime string-keyed collection access. Returns the full collection API with widened generics (DynamicNodeCollection/DynamicEdgeCollection), orundefinedif the kind is not registered. Eliminates the need forReflect.get(store.nodes, kind) as SomeTypepatterns when iterating kinds, resolving nodes from edge metadata, or building generic graph tooling like snapshots and summaries.
0.15.0
Section titled “0.15.0”Minor Changes
Section titled “Minor Changes”-
#63
546a7ebThanks @pdlug! -createStoreWithSchema()now auto-creates base tables on a fresh database. Previously, calling it against a database without pre-existing TypeGraph tables (e.g. a new Cloudflare Durable Object) would throw a raw “no such table” error. The function now detects missing tables and bootstraps them automatically via the new optionalbootstrapTablesmethod onGraphBackend. Both SQLite and PostgreSQL backends implement this method.createStore()remains unchanged for users who manage DDL manually. -
#64
6b84b42Thanks @pdlug! - AddStoreProjection<G, N, E>utility type for typing reusable helpers that work across graphs sharing a common subgraph. The type projects a store’s collection surface onto a subset of node and edge keys, with node constraint names erased so that graphs registering the same node types with different unique constraints remain cross-assignable. BothStore<G>andTransactionContext<G>are structurally assignable to anyStoreProjectionwhose keys are a subset ofG. Also exportsGraphNodeCollections<G>andGraphEdgeCollections<G>shared mapped types.
Patch Changes
Section titled “Patch Changes”-
#59
36742a1Thanks @pdlug! - Reject emptyfieldsarrays at the type level indefineNodeIndexanddefineEdgeIndex. Previously, passingfields: []was accepted by TypeScript but threw at runtime. Thefieldsproperty now requires a non-empty tuple, surfacing the error at compile time. -
#60
dca5abaThanks @pdlug! - ExportSchemaValidationResultandSchemaManagerOptionstypes from the root package entry point so users can type the return value ofcreateStoreWithSchema()without reaching into internal subpaths.
0.14.0
Section titled “0.14.0”Minor Changes
Section titled “Minor Changes”-
#54
bf6997aThanks @pdlug! - ### Breaking: default recursive traversal depth lowered from 100 to 10Unbounded
.recursive()traversals are now capped at 10 hops instead of 100. Graphs with branching factor B produce O(B^depth) rows before cycle detection can prune them — the previous default of 100 made exponential blowup easy to trigger accidentally.If your traversals relied on the implicit 100-hop cap, add an explicit
.maxHops(100)call. TheMAX_EXPLICIT_RECURSIVE_DEPTHceiling (1000) is unchanged.Schema parse validation
Section titled “Schema parse validation”Serialized schema documents read from the database are now validated against a Zod schema at the parse boundary. Malformed, truncated, or incompatible schema documents will throw a
DatabaseOperationErrorwith path-level detail instead of propagating silently. Enum fields (temporalMode,cardinality,deleteBehavior, etc.) are validated against the known literal unions.Type safety improvements
Section titled “Type safety improvements”- Added
useUnknownInCatchVariables,noFallthroughCasesInSwitch, andnoImplicitReturnsto tsconfig - Drizzle row mappers now use runtime type checks (
asString/asNumber) instead of unsafeascasts NodeMetaandEdgeMetaare now derived from row types via mapped types- All non-null assertions (
!) eliminated from source code - Hardcoded constants extracted to shared
constants.ts - Duplicate
fnv1aBase36function consolidated intoutils/hash.ts
- Added
0.13.0
Section titled “0.13.0”Minor Changes
Section titled “Minor Changes”-
#52
1e3da4aThanks @pdlug! - AddbatchFindFrom,batchFindTo, andbatchFindByEndpointsto edge collections for use withstore.batch().Edge collection lookup methods (
findFrom,findTo,findByEndpoints) execute immediately and cannot participate instore.batch(). The newbatchFind*variants return aBatchableQueryinstead, enabling edge lookups to share a single transactional connection alongside fluent queries.const [skills, employer, colleague] = await store.batch(store.edges.hasSkill.batchFindFrom(alice),store.edges.worksAt.batchFindFrom(alice),store.edges.knows.batchFindByEndpoints(alice, bob),);batchFindFrom(from)— deferred variant offindFrombatchFindTo(to)— deferred variant offindTobatchFindByEndpoints(from, to, options?)— deferred variant offindByEndpoints, returns 0-or-1 element array
All three preserve the same endpoint type constraints as their immediate counterparts.
Closes #51.
0.12.0
Section titled “0.12.0”Minor Changes
Section titled “Minor Changes”-
#50
a59416dThanks @pdlug! - Addstore.batch()for executing multiple queries over a single connection with snapshot consistency.- Single connection: Acquires one connection via an implicit transaction, eliminating pool pressure from parallel
Promise.allpatterns (N connections → 1). - Snapshot consistency: All queries see the same database state — no interleaved writes between results.
- Typed tuple results: Returns a mapped tuple preserving each query’s independent result type, projection, filtering, sorting, and pagination.
Correction (see #325). The “snapshot consistency” bullet above was never accurate and is retained only as the historical record.
batch()opens its implicit transaction without an isolation option, so PostgreSQL runs it at the default read-committed isolation and a later query in the batch can observe a commit the earlier ones did not. The “single connection” bullet describes the transactional path; connection reuse is otherwise the adapter’s business, not a consequence ofcapabilities.transactions.batch()also never pipelined, despite the original issue specifying it.BatchableQueryinterface: Satisfied by bothExecutableQuery(from.select()) andUnionableQuery(from set operations like.union(),.intersect()). ExposesexecuteOn()for backend-delegated execution.- Minimum 2 queries: Enforced at the type level — single queries should use
.execute()directly.
const [people, companies] = await store.batch(store.query().from("Person", "p").select((ctx) => ({ id: ctx.p.id, name: ctx.p.name })),store.query().from("Company", "c").select((ctx) => ({ id: ctx.c.id, name: ctx.c.name })).orderBy("c", "name", "asc").limit(5),);// people: readonly { id: string; name: string }[]// companies: readonly { id: string; name: string }[]Closes #47.
- Single connection: Acquires one connection via an implicit transaction, eliminating pool pressure from parallel
-
#48
753d9ebThanks @pdlug! - Add field-level projection tostore.subgraph()via a declarativeprojectoption.- Declarative field selection: Specify which properties to keep per node/edge kind. Projected nodes always retain
kindandid; projected edges always retain structural endpoint fields. Kinds omitted fromprojectremain fully hydrated. - SQL-level extraction: Projected property fields are extracted via
json_extract()/ JSONB path expressions directly in the query, avoiding fullpropsblob transfer for projected kinds. - All-or-nothing metadata: Include
"meta"in the field list for the full metadata object, or omit it entirely. No partial metadata selection — the struct is small enough that subsetting adds complexity without meaningful savings. defineSubgraphProject()helper: Curried identity function that preserves literal types for reusable projection configs. Without it, storing a projection in a variable widens field arrays tostring[], defeating compile-time narrowing.- Type-safe results: Result types narrow per-kind based on the projection — accessing omitted fields is a compile-time error. Works through both inline literals and
defineSubgraphProject().
const result = await store.subgraph(rootId, {edges: ["has_task", "uses_skill"],maxDepth: 2,project: {nodes: {Task: ["title", "meta"],Skill: ["name"],},edges: {uses_skill: ["priority"],},},});// result.nodes — Task has { kind, id, title, meta }; Skill has { kind, id, name }// result.edges — uses_skill has { id, kind, fromKind, fromId, toKind, toId, priority }Closes #46 (alternative implementation — declarative arrays instead of callbacks).
- Declarative field selection: Specify which properties to keep per node/edge kind. Projected nodes always retain
0.11.1
Section titled “0.11.1”Patch Changes
Section titled “Patch Changes”-
#41
68d5432Thanks @pdlug! - Fix.paginate()droppingidfrom selective query results andorderBy()mishandling system fields.- Fix silent data loss in
.paginate()+.select():FieldAccessTracker.record()no longer allows a system field (id,kind) to be downgraded to a props field, which caused the SQL projection to extract fromprops->>'id'(nonexistent) instead of theidcolumn. - Fix
orderBy()for system fields:orderBy("alias", "id")now emitsORDER BY cte.alias_idinstead ofORDER BY json_extract(cte.alias_props, '$.id'). - Add
gt/gte/lt/ltetoStringFieldAccessor: Enables keyset cursor pagination viawhereNode("a", (a) => a.id.lt(cursor)).
Fixes #40.
- Fix silent data loss in
0.11.0
Section titled “0.11.0”Minor Changes
Section titled “Minor Changes”-
#38
e26e4a5Thanks @pdlug! - AddcreateFromRecord()andupsertByIdFromRecord()toNodeCollection.These methods accept
Record<string, unknown>instead ofz.input<N["schema"]>, providing an escape hatch for dynamic-data scenarios (changesets, migrations, imports) where the data shape is determined at runtime. Runtime Zod validation is unchanged — only the compile-time type gate is relaxed. The return type remains fully typed asNode<N>.Closes #37.
0.10.0
Section titled “0.10.0”Minor Changes
Section titled “Minor Changes”-
#33
da14806Thanks @pdlug! - Addstore.subgraph()for typed BFS neighborhood extraction from a root node.Given a root node ID, traverses specified edge kinds using a recursive CTE and returns all reachable nodes and connecting edges as fully typed discriminated unions.
Options:
edges— edge kinds to traverse (required)maxDepth— maximum traversal depth (default: 10)direction—"out"(default) or"both"for undirected traversalincludeKinds— filter returned nodes to specific kinds (traversal still follows all reachable nodes)excludeRoot— omit the root node from resultscyclePolicy— cycle detection strategy (default:"prevent")
Type utilities exported:
AnyNode<G>/AnyEdge<G>— discriminated unions of all node/edge runtime types in a graphSubsetNode<G, K>/SubsetEdge<G, K>— narrowed unions for a subset of kindsSubgraphOptions<G, EK, NK>/SubgraphResult<G, NK, EK>— fully generic option and result types
-
#35
0ebc59cThanks @pdlug! - Add runtime discriminated union types:AnyNode<G>,AnyEdge<G>,SubsetNode<G, K>,SubsetEdge<G, K>.These pure type-level utilities produce discriminated unions of runtime node/edge instances from a graph definition. Unlike
AllNodeTypes<G>(union of type definitions),AnyNode<G>gives the union of runtimeNode<T>values — discriminated bykindfor exhaustiveswitchnarrowing.SubsetNode<G, K>narrows the union to a specific set of kinds.
Patch Changes
Section titled “Patch Changes”-
#27
c2f0811Thanks @pdlug! - Fixcount(alias, field)andcountDistinct(alias, field)ignoring the field argument in SQL compilation.Both functions always compiled to
COUNT(alias_id)/COUNT(DISTINCT alias_id)regardless of the field argument, because:- The aggregate emitters in
standard-builders.tsandset-operations.tshardcoded_idfor count/countDistinct instead of callingcompileFieldValue()like sum/avg/min/max do. collectRequiredColumnsByAliasinstandard-pass-pipeline.tsexplicitly skipped marking the field as required for count/countDistinct, so the CTE wouldn’t include the_propscolumn even if the emitter were fixed.
Now
count("p", "email")correctly compiles toCOUNT(json_extract(p_props, '$."email"'))andcountDistinct("b", "genre")compiles toCOUNT(DISTINCT json_extract(b_props, '$."genre"')). - The aggregate emitters in
Patch Changes
Section titled “Patch Changes”-
#24
733bf8aThanks @pdlug! - FixcheckUniqueBatchexceeding SQL bind parameter limit on SQLite/D1/Durable Objects.Bulk constraint operations (
bulkGetOrCreateByConstraint,bulkFindByConstraint) passed all keys in a singleIN (...)clause. With hundreds of unique keys, this exceeded SQLite’s 999 bind parameter limit, causingSQLITE_ERROR: too many SQL variables.The fix chunks the keys array in
checkUniqueBatchusing the same pattern already used bygetNodes,insertNodesBatch, and other batch operations. SQLite chunks at 996 keys per query (999 max − 3 fixed params), PostgreSQL at 65,532.
Minor Changes
Section titled “Minor Changes”-
#21
88beee4Thanks @pdlug! - AddtransactionModeto SQLite execution profile, fixing Cloudflare Durable Object compatibility.createSqliteBackendpreviously used rawBEGIN/COMMIT/ROLLBACKSQL for all sync SQLite drivers. This crashes on Cloudflare Durable Object SQLite (viadrizzle-orm/durable-sqlite) because the driver does not support raw transaction SQL throughdb.run().The new
transactionModeoption ("sql"|"drizzle"|"none") controls how transactions are managed:"sql"— TypeGraph issuesBEGIN/COMMIT/ROLLBACKdirectly (default for better-sqlite3, bun:sqlite)"drizzle"— delegates to Drizzle’sdb.transaction()(default for async drivers)"none"— transactions disabled (default for D1 and Durable Objects)
D1 and Durable Object sessions are auto-detected by Drizzle session name. Users can override via
executionProfile: { transactionMode: "..." }.Breaking:
isD1removed fromSqliteExecutionProfileHintsandSqliteExecutionProfile. UsetransactionMode: "none"instead.D1_CAPABILITIESremoved — capabilities are now derived fromtransactionMode.
Minor Changes
Section titled “Minor Changes”-
#19
5b1dec6Thanks @pdlug! - Support unconstrained edges indefineGraph.Edges defined without
from/toconstraints (e.g.,defineEdge("sameAs")) can now be passed directly todefineGraphwithout anEdgeRegistrationwrapper. They are automatically allowed to connect any node type in the graph to any other.EdgeEntrywidened — accepts anyEdgeType, not just those with endpointsNormalizedEdges— falls back to all graph node types whenfrom/toare undefined- Constrained edges,
EdgeRegistrationwrappers, and narrowing validation are unchanged
Minor Changes
Section titled “Minor Changes”-
#16
0a2f08fThanks @pdlug! - Tighten type safety across store and collection APIs.Breaking:
TypedNodeRef<N>has been renamed toNodeRef<N>and the old untypedNodeRefhas been removed. ReplaceTypedNodeRef<N>withNodeRef<N>— the type is structurally identical. UnparameterizedNodeRef(with the new default) covers the old untyped usage.EdgeId<E>— branded edge ID type, mirroringNodeId<N>. Prevents mixing IDs from different edge types at compile time.Edge<E, From, To>— edge instances now carry endpoint node types.edge.fromIdisNodeId<From>,edge.toIdisNodeId<To>, andedge.idisEdgeId<E>.getNodeKinds/getEdgeKinds— returnreadonly (keyof G["nodes"] & string)[]instead ofreadonly string[].constraintNameliteral unions —findByConstraint,getOrCreateByConstraint, and their bulk variants now only accept constraint names that exist on the node registration, catching typos at compile time.
Minor Changes
Section titled “Minor Changes”-
#14
45624e0Thanks @pdlug! - Restructure SQLite/Postgres entry points to decouple DDL generation from native dependencies.Breaking changes:
./drizzle,./drizzle/sqlite,./drizzle/postgres,./drizzle/schema/sqlite,./drizzle/schema/postgresentry points are removed. Import backend factories, schema tables/factories, and DDL helpers from./sqliteand./postgres.createLocalSqliteBackendmoves from./sqliteto./sqlite/local. The./sqliteentry point no longer depends onbetter-sqlite3.getSqliteMigrationSQLis renamed togenerateSqliteMigrationSQL.getPostgresMigrationSQLis renamed togeneratePostgresMigrationSQL.- Individual table type aliases (
NodesTable,EdgesTable,UniquesTable,SchemaVersionsTable,EmbeddingsTable) are removed from both schema modules. UseSqliteTables["nodes"]orPostgresTables["edges"]instead.
Migration guide:
Before After import { ... } from "@nicia-ai/typegraph/drizzle/sqlite"import { ... } from "@nicia-ai/typegraph/sqlite"import { ... } from "@nicia-ai/typegraph/drizzle/postgres"import { ... } from "@nicia-ai/typegraph/postgres"import { ... } from "@nicia-ai/typegraph/drizzle/schema/sqlite"import { ... } from "@nicia-ai/typegraph/sqlite"import { ... } from "@nicia-ai/typegraph/drizzle/schema/postgres"import { ... } from "@nicia-ai/typegraph/postgres"import { createLocalSqliteBackend } from "@nicia-ai/typegraph/sqlite"import { createLocalSqliteBackend } from "@nicia-ai/typegraph/sqlite/local"getSqliteMigrationSQL()generateSqliteMigrationSQL()getPostgresMigrationSQL()generatePostgresMigrationSQL()NodesTable,EdgesTable,UniquesTable,SchemaVersionsTable,EmbeddingsTableSqliteTables["nodes"]/PostgresTables["nodes"](and corresponding table keys)
Minor Changes
Section titled “Minor Changes”-
#12
c40b8a4Thanks @pdlug! - Add read-only lookup methods and store-level clear for graph data management.New APIs:
findByConstraint/bulkFindByConstraint— look up nodes by a named uniqueness constraint without creating. ReturnsNode<N> | undefined(or(Node<N> | undefined)[]for bulk). Soft-deleted nodes are excluded.findByEndpoints— look up an edge by(from, to)with optionalmatchOnproperty fields without creating. ReturnsEdge<E> | undefined. Soft-deleted edges are excluded.store.clear()— hard-delete all data for the current graph (nodes, edges, uniques, embeddings, schema versions). Resets collection caches so the store is immediately reusable with raw, unversioned semantics; reopen it through a managed factory before relying on schema-version fencing.
Minor Changes
Section titled “Minor Changes”-
#10
550eec6Thanks @pdlug! - Add node and edge get-or-create operations with explicit API naming.New APIs:
getOrCreateByConstraint/bulkGetOrCreateByConstraint— deduplicate nodes by a named uniqueness constraintgetOrCreateByEndpoints/bulkGetOrCreateByEndpoints— deduplicate edges by(from, to)with optionalmatchOnproperty fieldshardDeletefor node and edge collectionsaction: "created" | "found" | "updated" | "resurrected"result discriminant
Breaking changes:
upsert→upsertById,bulkUpsert→bulkUpsertByIdonConflict: "skip" | "update"→ifExists: "return" | "update"ConstraintNotFoundError→NodeConstraintNotFoundError- Removed generic
FindOrCreate*type exports in favor of explicitNodeGetOrCreateByConstraint*andEdgeGetOrCreateByEndpoints*types
Patch Changes
Section titled “Patch Changes”- #8
4732792Thanks @pdlug! - FixAnyPgDatabasetype to accept standard Drizzle instances created without an explicit schema
Minor Changes
Section titled “Minor Changes”-
#6
4553aedThanks @pdlug! - Big performance increases, cleaner APIs, prepared queries, and batch collection APIs.Breaking Changes
Section titled “Breaking Changes”Renamed APIs:
selectAggregate()is nowaggregate()EdgeTypeNames/NodeTypeNamesare nowEdgeKinds/NodeKinds(including getter functions)
Traversal expansion:
includeImplyingEdgesreplaced withexpandoption supporting four modes:"none","implying","inverse", and"all"(default:"inverse")Recursive traversal: The chained methods
.maxHops(),.minHops(),.collectPath(), and.withDepth()are consolidated into a singlerecursive()call with an options object:// Before.traverse("p", "knows", "friend").recursive().maxHops(5).collectPath()// After.traverse("p", "knows", "friend").recursive({ maxHops: 5, path: true })New
cyclePolicy: "prevent" | "allow"option (default:"prevent"). Unbounded recursion capped at depth 100; explicitmaxHopsvalidated up to 1,000.Store:
Storeclass is now a type-only export — usecreateStore().StoreConfigreplaced byStoreOptions.Moved to
@nicia-ai/typegraph/schema: All schema management APIs (serializeSchema,deserializeSchema,initializeSchema,ensureSchema,migrateSchema,computeSchemaDiff,getMigrationActions,isBackwardsCompatible, and related types) are now imported from the new@nicia-ai/typegraph/schemaentry point.Removed from main entry:
KindRegistry, Result utilities (ok/err/isOk/isErr/unwrap/unwrapOr), date helpers (encodeDate/decodeDate), validation utilities, and compiler/profiler internals.New Features
Section titled “New Features”Prepared queries — precompile queries once and execute repeatedly with different bindings at zero recompilation cost:
const prepared = store.query().from("Person", "p").whereNode("p", (p) => p.name.eq(param("name"))).select((ctx) => ctx.p).prepare();const alice = await prepared.execute({ name: "Alice" });const bob = await prepared.execute({ name: "Bob" });Batch collection APIs:
getByIds(ids)— batched lookup preserving input order, returnsundefinedfor missing IDsbulkInsert— void-returning fire-and-forget ingestionbulkCreate— multi-rowINSERT ... RETURNINGinstead of per-item insertsbulkUpsert(edges) — batch lookup instead of N+1 sequential calls
Node
find({ where })— filter nodes using the full query predicate system directly from collections.Performance
Section titled “Performance”- SQL compiler restructured into plan/passes/emitter pipeline with predicate pre-indexing, column pruning, and single-hop recursive lowering
- Drizzle backend split into modular operations with dialect-driven strategy dispatch
- SQLite prepared statement caching with LRU eviction
- Compilation caching on immutable query builder instances
- Bind-limit-aware batch chunking (SQLite: 999 params, PostgreSQL: 65,535 params)
- Benchmark regression guardrails added to CI for both SQLite and PostgreSQL
Minor Changes
Section titled “Minor Changes”bdd5f34Thanks @pdlug! - Improve support for custom table names and use web crypto to support both node and edge runtimes.