Changelog
Release notes for @nicia-ai/typegraph. Generated from packages/typegraph/CHANGELOG.md on every build.
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:00Z",});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.
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.
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.