rust-store

rust-store

A single Rust core engine for multi-backend data access — GQL parsing, permission checks, computed columns, command planning and SQL dialect translation, exposed to Node.js and Python through native bindings.

crates.io npm version PyPI version license rust bindings

rust-store is the engine behind nodejs-store and py-store. It contains no database driver and performs no IO: it turns a query (GQL) or a write into a backend-agnostic command (MongoDB command JSON) for its host to execute, and translates those commands into parameterized SQL for MySQL / PostgreSQL / SQLite.

中文文档见 README.zh-CN.md

Documentation site: https://coenddt.github.io/rust-store/ — every scenario walkthrough with runnable code and the engine’s exact limits, one indexable page per scenario.


Table of contents


What it is

A multi-backend data engine written once in Rust and shared by every host language.

Core principles (enforced across the codebase):

  1. No IO, no clock, no randomness. The core never opens a connection and never reads the system clock. now and newId(s) are always passed in by the host — this is what makes the engine deterministic and cross-language reproducible.
  2. Explicit over silent. Anything that cannot be translated safely raises an error or emits a structured unsupported + warning. The engine never produces SQL that is quietly missing a clause.
  3. JSON in, JSON out. Bindings only convert and forward; they add no behaviour, so Node.js and Python cannot drift apart semantically.

How it differs from specific projects

Positioning only, based on those projects’ public documentation at the time of writing — verify against your own requirements.

When to use it

How it relates to nodejs-store and py-store

                 ┌──────────────────────────────┐
   Node.js  ──▶  │  nodejs-store (npm, host)    │ ─┐
                 └──────────────────────────────┘  │  rust-store-node (napi-rs)
                                                   ▼
                                     ┌───────────────────────────────┐
                                     │ rust-store/core (pure logic)  │
                                     │ GQL · permissions · computes  │
                                     │ command planning · dialects   │
                                     └───────────────────────────────┘
                                                   ▲
                 ┌──────────────────────────────┐  │  rust-store-py (PyO3)
   Python   ──▶  │  py-store (pip, host)        │ ─┘
                 └──────────────────────────────┘

If you only want to use the data layer, install nodejs-store or storepy — you do not need this repo directly.

Workspace layout

Directory Crate / package Purpose
core/ rust-store-core Language-agnostic core: GQL / permissions / computed columns / command planning. Pure logic, no IO.
core-node/ rust-store-node Node binding (napi-rs) → dist/rust-store-node.node. Published to npm as rust-store-node.
core-py/ rust-store-py Python binding (PyO3) → dist/rust_store_py.pyd. Published to PyPI as rust-store-py.

The workspace shares a single root target/ and root Cargo.lock. All three crates are publish = false (nothing is published to crates.io; the bindings ship through npm and PyPI).

Internal modules worth knowing: pipeline/ (GQL parse → AST → $lookup/$group build), command/ (query/count/write/mutation planners), dialect/ (filter, select, write, row rehydration, introspection, overlay), computes/ (sync / async / agg), permission.rs, federation/, schema/.

Installation

Bindings (what hosts depend on):

npm i rust-store-node          # Node binding; platform natives ship as optionalDependencies
pip install rust-store-py      # Python binding (maturin wheel)

Rust library:

cargo add rust-store-core     # pure logic: GQL → command JSON → SQL, no IO

API docs are built automatically on docs.rs. Working from a checkout instead? Use a path dependency: rust-store-core = { path = "path/to/rust-store/core" }.

Build toolchain for development: Rust stable (edition 2021) with cargo / clippy / rustfmt; napi-rs CLI for core-node; maturin >= 1.7, < 2.0 for core-py.

Building the bindings locally:

# Node binding (core-node/)
npx napi build --platform --release          # release flow: .github/workflows/release-npm.yml

# Python binding (core-py/)
maturin build --manifest-path core-py/Cargo.toml --release

Quick start

Node.js (core-node, camelCase)

const { Registry } = require('rust-store-node');

const reg = new Registry();
reg.register({ name: 'User', collection: 'users', fields: { name: { type: 'string' } }, relations: {} });

const plan = reg.planQuery('User{name}', {}, null);   // → MongoDB command JSON

Sync computed-column callbacks are registered with setFn; asyncFn computed columns use the two-phase prepareQuery / stripQuery flow.

Python (core-py, snake_case)

from rust_store_py import Registry

reg = Registry()
reg.register({"name": "User", "collection": "users", "fields": {"name": {"type": "string"}}, "relations": {}})

plan = reg.plan_query("User{name}", {}, None)  # → MongoDB command JSON (dict)

Sync computed columns use set_fn; asyncFn uses the same two-phase flow. Errors are always raised as Python exceptions (PyErr) — they are never mixed into the returned dict.

System context

ctx is an explicit parameter on every plan method. { internal: true } marks a system call (permission engine passes everything, no owner injection), which is semantically different from undefined/None (no context).

API reference

Node and Python names correspond one-to-one (camelCase ↔ snake_case). All methods below exist in both bindings.

Lifecycle and schema

Node Python Notes
new Registry() Registry() Explicit registry instance (no module-level global)
register(defn) register(defn) Registers a schema; auto-derives the <Name>Deleted archive table
has(name) / list() has(name) / list()  
setFn(fnRef, cb) / clearFns() set_fn(fn_ref, cb) / clear_fns() Sync computed-column callbacks
setRequireContext(bool) / requireContext() set_require_context(bool) / require_context() Fail-secure switch (default off)

The binding Registry has no get(name) — hosts keep their own schema dictionary.

Read path

buildPipeline / build_pipeline, planQuery / plan_query, planQueryOne / plan_query_one, planQueryWithCount / plan_query_with_count, resolvePage / resolve_page, restoreSortOrder / restore_sort_order, planExists / plan_exists, planCount / plan_count, sortsByRelation / sorts_by_relation.

Notes: planQueryOne forces $limit(1) when no explicit $limit is present. planQueryWithCount’s total is supplied by the host after it runs the countCommand.

Write path

planInsert / plan_insert, planInsertMany / plan_insert_many, planUpdate / plan_update, planUpdateMany / plan_update_many, planRemove / plan_remove, planArchiveDocs / plan_archive_docs, planUpsert / plan_upsert, planMutation / plan_mutation, applyWriteDefaults / apply_write_defaults.

Notes:

Permissions

canRead / can_read, canWrite / can_write, shouldInjectOwner / should_inject_owner, mergeOwnerCondition / merge_owner_condition, readableFields / readable_fields, readableRelations / readable_relations, writableFields / writable_fields, filterWritableData / filter_writable_data.

Computed columns and post-processing

processNode / process_node, asyncFnRefs / async_fn_refs, injectDepends / inject_depends, stripDepInjected / strip_dep_injected, prepareQuery / prepare_query (phase one, returns {items, fnRefs}), stripQuery / strip_query (phase three).

Datasource, dialect, federation

Node Python Notes
resolveDatasource(schemaName, config) resolve_datasource(...) Returns "mongo" / "mysql" / "postgres" / "sqlite"
schemaDatasource(schemaName) schema_datasource(...) null when undeclared (semantics: default)
dialectTranslate(backend, cmd) dialect_translate(...) MongoDB command JSON → SQL statement sequence
restoreRows(shape, rows) restore_rows(...) Flat rows → nested documents
schemaFromRows(rows, backend) schema_from_rows(...) Introspection rows → schemaJSON
mergeSchema(base, overlay) merge_schema(...) Physical structure + local overlay
planFederated(gql, params, ctx, dsConfig) plan_federated(...) Splits one GQL into per-source commands + in-memory join edges; result includes degraded
mergeFederated(plan, results) merge_federated(...) results must match plan.sources in order and length

Module-level: systemContext() (Node) / system_context() (Python).

APIs that do not exist (do not invent them): there is no aggregate passthrough, no public parseGql / parse_gql method (parsing is internal via pipeline::parse_gql), and no IO / driver / execution method of any kind.

GQL capabilities

ModelName($condition:@c0,$sort:@s1,$skip:@sk,$limit:@l1) {
  field1, field2, obj.subField,
  RelationName($condition:@c2,$sort:@s3,$limit:@l2) { field3, NestedRelation { field4 } }
}

Root-level $group / $having

Course($condition:@c0, $group:@g0, $having:@h0, $sort:@s0, $skip:@sk, $limit:@l0) { status, n, total }

Relation aggregate predicates (§9.6)

Filter parents by an aggregate over a relation — a semi-join with no fan-out:

Computed columns

Declared in the schema; three forms:

Form Evaluated Notes
fn host (sync, via set_fn) dependencies are injected into the projection automatically
asyncFn host (async, two-phase) same injection, resolved after the query returns
agg engine inline {"$count": "<relation>"} or {"$sum"|"$avg"|"$min"|"$max": "<relation>.<field>"}

agg is mutually exclusive with fn / asyncFn. Empty-set semantics: $count → 0, others → None (nullable). SQL uses a derived table LEFT JOIN (… GROUP BY fk); MongoDB uses $lookup + $addFields.

Backends and dialects

Datasource registration: the host passes dsConfig = { "sources": { "<name>": "<kind>" } } (null = single-source Mongo). SQL joins across namespaces of the same source are still pushed down (qualified JOIN); Mongo cross-database relations degrade to in-memory federation.

Cross-backend translations: root $group / $havingGROUP BY / HAVING; the $count/$sum/$avg/$min/$max whitelist; relation aggregate predicates → EXISTS / NOT EXISTS (WHERE EXISTS (SELECT 1 … GROUP BY fk HAVING …)); relation-rolling agg computed columns → derived table LEFT JOIN (… GROUP BY fk); per-parent top-N ($sort/$skip/$limit inside a relation) → ROW_NUMBER() OVER (PARTITION BY fk ORDER BY …).

Mongo-only or explicitly-unsupported on SQL: object dot-paths in $group.by (Mongo can execute, SQL cannot map to a scalar column → Err); root $sort keys that cannot be mapped (unknown field / object or array field / relation name itself / no matching relation drill-down) are simply not pushed down (warning + host-side fallback sort) while Mongo executes them normally.

Permission model

Schema-level read / write, field-level field.read / field.write, relation-level rel.read, computed-column-level comp.read.

Guarding helpers for AI query hosts: timestamps value validation (only true / false / "ms" / "s", invalid values fail at registration) and federation degraded events ({code, layer, message, hint}, returned in plan.degraded) so non-pushdownable cross-source pagination/sort never blocks a query silently. See core/tests/guards.rs.

Testing and parity

# 1) core (pure Rust, no host dependency)
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test -p rust-store-core

# 2) Node binding (core-node/)
npm ci
npx napi build --platform
npm test

# 3) Python binding (repo root)
cd core-py && pip install maturin pytest && maturin build --out dist && pip install --force-reinstall dist/*.whl
cd .. && python -m pytest core-py/test/parity_test.py -v

# 4) Golden baseline, recomputed on all three sides
node tools/verify-fixtures.js

Boundaries and gotchas

  1. No clock, no randomness in core. now / newId(s) must be supplied by the host, which is what makes cross-language results reproducible.
  2. ctx is explicit on every plan method. Omitting it means “no context” (permissive by default; an error when require_context is on).
  3. require_context defaults to off (fail-open) for parity with the original JS implementation. Turn it on at host startup and pass systemContext() for internal calls to get fail-secure behaviour.
  4. Stable error prefixes: ERR_PERM_PREFIX, ERR_NO_WRITE, ERR_NO_BATCH_WRITE, ERR_NO_CONTEXT (core/src/command/mod.rs). Hosts map these to their own error types. Python must raise them as PyErr, never return them.
  5. Empty-condition batch writes are rejected: updateMany / remove with {}, null or an empty logical group ({"$and":[]} / {"$or":[]}) is treated as unconditional and explicitly refused — it never touches a whole table.
  6. __present is an internal SQL sentinel column: it distinguishes “explicit null (key present)” from “missing (no key)”. It is injected and consumed by the translation layer; in PostgreSQL ON CONFLICT DO UPDATE, references must be table-qualified or you get column reference "__present" is ambiguous.
  7. U1–U4 are global errors: filtering directly on array fields (U1), deep equality on object fields (U2), object dot-path filtering (U3) and object dot-path sorting (U4) all raise on every backend; empty logical groups raise too. Relation-path sorting is not object dot-path sorting and is unaffected.
  8. timestamps validation: only true / false / "ms" / "s" (default ms); invalid values fail at registration. Unit conversion is the host’s clock’s job.
  9. Federation degraded does not block: non-pushdownable cross-source pagination/sort produces structured events the host is expected to feed into an automated feedback loop.
  10. Untranslatable means explicit (project rule): translation must raise or emit unsupported + warning; it never emits SQL that is missing a clause.
  11. planFederated results must match plan.sources in order and length, or mergeFederated will misalign.

FAQ

Is rust-store an ORM? No. It is a planning and translation engine. It holds no driver, opens no connection and executes nothing — the host runs every command.

Do I need this repo to use the data layer? No. Install nodejs-store (npm) or storepy (PyPI). This repo matters if you are building, debugging or extending the engine itself, or writing a host for another language.

How do I write GQL queries? See GQL capabilities. The full syntax and examples are also documented in the companion text-to-query skill, which turns natural-language questions into GQL + params.

Why do Node.js and Python behave identically? Both bindings wrap the same Rust core and only convert JSON. No logic is duplicated in the hosts, so semantics cannot drift. The parity suites and golden fixtures exist to prove this continuously.

How does aggregation work across four different databases? Root-level $group / $having map to GROUP BY / HAVING; relation aggregate predicates map to EXISTS / NOT EXISTS; relation-rolling agg computed columns map to a derived-table LEFT JOIN. MongoDB uses its native pipeline. All four backends are covered by the same semantics.

What happens when something cannot be translated to SQL? The engine raises explicitly or emits an unsupported + warning event. It never produces SQL that silently omits a clause. MongoDB can still execute a few things SQL cannot (e.g. object dot-paths in $group.by), which is why those cases are errors only on the SQL side.

Can I use it from Rust directly? Yes — rust-store-core is a plain Rust library (publish = false, so depend on it by path). You register schemas, plan queries and translate commands, then execute them with the driver of your choice.

License

MIT