Node.js binding for Minigraf — zero-config, single-file, embedded bi-temporal graph database with Datalog queries.
npm install minigraf| Platform | Architecture | Support |
|---|---|---|
| Linux | x64 | ✅ |
| Linux | arm64 | ✅ |
| macOS | universal (x64 + arm64) | ✅ |
| Windows | x64 | ✅ |
Requires Node.js 18+.
import { MiniGrafDb } from 'minigraf'
// In-memory database
const db = MiniGrafDb.inMemory()
db.execute('(transact [[:alice :name "Alice"] [:alice :age 30]])')
const result = JSON.parse(db.execute('(query [:find ?n :where [?e :name ?n]])'))
console.log(result.results[0][0]) // "Alice"
// File-backed (persisted to disk)
const db2 = new MiniGrafDb('path/to/mydb.graph')
db2.execute('(transact [[:bob :name "Bob"]])')
db2.checkpoint()
db2.close()Only one handle per .graph file may be open in a process at a time — two
would each cache their own page table and corrupt the file — so call close()
when you are done with one:
const db = new MiniGrafDb('mydb.graph')
db.execute('(transact [[:bob :name "Bob"]])')
db.checkpoint()
db.close()
const again = new MiniGrafDb('mydb.graph') // fine: the first handle is goneLetting the variable go out of scope is not enough. JavaScript has no
deterministic destructor: the object becomes unreachable, but the underlying
handle survives until V8 garbage-collects it, which may be arbitrarily later or
not at all before the process exits. Reopening before then throws
Database is already open in this process.
close() is idempotent; every other method throws once it has run. In-memory
databases hold no file lock, so closing them is optional.
const { MiniGrafDb, LogWriter } = require('minigraf')
// Read-only: shared lock, nothing written; writes throw "[API-014] ...".
const src = MiniGrafDb.openWithOptions('old.graph', { readOnly: true, pageCacheSize: 4096 })
// A cursor's answer is fixed when it opens. Iterate over rows (decoded like
// execute()'s results), or call nextBatch(n) for a JSON string of up to n rows.
for (const row of src.query('(query [:find ?n :where [?e :name ?n]])')) console.log(row)
// Copy every fact version, keeping tx and valid-time bounds, into a new file.
const out = LogWriter.create('new.graph')
try {
const log = src.factLog({ attributePrefixes: [':app/'] })
for (let batch; (batch = log.nextBatch(10000)) !== null; ) out.appendBatch(batch)
out.advanceTxCount(src.currentTxCount())
out.finish()
} finally {
out.close() // abandons the build (no file) unless finish() ran
}Options: readOnly, pageCacheSize, allowUnlocked, walCheckpointThreshold,
maxDerivedFacts, maxResults, synchronous ('full' or 'normal'),
queryMemoryLimit. Fact filter:
attributes, attributePrefixes, entities (UUID strings), txFrom/txTo
(inclusive, bigint), order ('tx' or 'storage'), window.
A record's txCount, txId, validFrom and validTo are bigints (validTo is
VALID_TIME_FOREVER for forever), and its value is { type, value } with type one
of 'string', 'integer' (a bigint), 'float', 'boolean', 'ref' (a UUID string),
'keyword' or 'null', so a record is written back exactly. Error messages start with
their code, such as [API-015]; appendBatch ends the message with (batch index N).
queryMemoryLimit caps the bytes one query may hold, counted per entry by the operators
that keep state (join build sides, negation and or tables, distinct sets, aggregate
groups, window rows, rule-derived facts). A query that would hold more throws QRY-012 and
frees what it held. The default, and Number.MAX_SAFE_INTEGER or more, is no limit. Rows
a cursor returns are not counted, so read a large answer with query().
db.prepare(text) parses a query once; execute(bindings) and query(bindings) run it
with its $slots bound (names without the $). Each run reads the database as it is then.
const pq = db.prepare('(query [:find ?n :where [?e :age $age] [?e :name ?n]])')
JSON.parse(pq.execute({ age: 30 })).results
for (const row of pq.query({ age: 40n })) console.log(row)A boolean, a string, null, a bigint and a number bind as values; a number is an
integer when Number.isInteger accepts it, else a float. Use a typed form for the other
kinds: a FactValue such as { type: 'float', value: 2 }, { type: 'keyword', value: ':k' }
or { type: 'ref', value: uuid }; { type: 'entity', value: uuid }; { type: 'txCount', value: 5 } or { type: 'timestamp', value: Date.now() } for :as-of; and
{ type: 'anyValidTime' } for :valid-at. Anything else throws a TypeError. A missing
slot throws [API-022], a slot in an attribute position [API-023], and a value of the
wrong kind [API-024].
A set binds as a JS Set or { type: 'set', value: [...] }, for in? and the other set
predicates or a set-valued attribute: pq.execute({ ids: new Set([1, 2, 3]) }). Its
elements are plain values or typed value forms ({ type: 'keyword', value: ':role/admin' }).
An element in an entity, tx count, timestamp or any-valid-time form throws [API-017], a
set inside a set [API-021]; a plain array is not a set (TypeError). Set values come
back as JSON arrays from execute() and as { type: 'set', value: [FactValue, ...] } in
fact records.
db.close() also releases every prepared query made from it, so the path can be opened
again; a prepared query used after that throws [API-018] database is closed.
db.registerPredicate(name, fn) adds a predicate, called in a query as [(name ?v)].
db.registerAggregate(name, { init, step, finish }) adds an aggregate, used in :find
as (name ?v) and as a window function with :over: init() returns a new
accumulator (one per group or window partition), step(acc, value) returns the
accumulator after value, and finish(acc, count) returns the result, count being
the number of values stepped. All are called as plain functions, with no this.
db.registerPredicate('adult?', (v) => v.type === 'integer' && v.value >= 18n)
db.registerAggregate('product', {
init: () => 1n,
step: (acc, v) => acc * v.value,
finish: (acc) => acc,
})
db.execute('(query [:find ?n :where [?e :age ?a] [(adult? ?a)] [?e :name ?n]])')
db.execute('(query [:find (product ?a) :where [?e :age ?a]])')- Values. A function receives each value as a
FactValue,{ type, value }, as in fact records:typeis'string','integer'(valueabigint),'float','boolean','ref'(an entity UUID string),'keyword','null'(novalue) or'set'(valuean array of these). A predicate returns a boolean. An accumulator may be any value.finishreturns a value in any form a prepared query's bindings accept: a plain value or a typed{ type, value }form. - Errors. A function that throws fails the query with
[QRY-014], carrying the error'smessage(elseString(e)), and so does a predicate that returns anything but a boolean, or afinishwhose result is not a value. It is never read asfalseor a missing value. Registration throws aTypeErrorwhen a function is missing, and[INT-040]when the name is taken (predicates and aggregates have separate names). - Lifecycle. A query that is already open (a cursor) does not see a function registered after it; every query opened afterwards does. The database keeps each function until it, and every cursor and prepared query opened from it, are released. There is no unregister.
- Threading. A function runs on the JavaScript thread that calls
execute,nextBatchor a prepared query, which is the thread that registered it. A database opened in a worker thread keeps its functions in that worker. - Cost. Each value is one call into JavaScript, so a function over many rows costs one call per value.
- Calling the database from a function. A function may query the same database
(
execute,query,prepareand the prepared query'sexecute/query), write, checkpoint, register another function, orclose()it. The query that called it does not see a write it makes, and keeps running after aclose(). A function that callsnextBatch()on the cursor whose batch called it fails its call.
Requires Rust stable toolchain and @napi-rs/cli.
npm install
npx napi build --platform --release --no-jsThis repo receives a core-release repository_dispatch from the minigraf monorepo
cascade whenever a new version of the minigraf core crate is published. The release
workflow pins the new version, commits, tags, builds native .node binari
59EF
es for all
platforms, and publishes to npm.
MIT OR Apache-2.0