nodejs-store 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,29 +1,135 @@
1
1
  # nodejs-store
2
2
 
3
- A lightweight multi-backend data layer for Node.js — define your models as pure JSON schemas, query with GQL tree syntax, and get role-based access control out of the box. One unified MongoDB-style dialect runs on **MongoDB, MySQL, SQLite and PostgreSQL**.
3
+ **One data layer for MongoDB, MySQL, SQLite and PostgreSQL — define models as pure JSON, query them with a MongoDB-style GQL tree syntax, and get role-based access control, computed columns and soft-delete out of the box.**
4
4
 
5
- This is the Node.js port of [`py-store`](https://github.com/coenddt/py-store) — same schemas, same GQL, same semantics, camelCase API. Both are thin hosts over the shared Rust core in [`rust-store`](https://github.com/coenddt/rust-store).
5
+ ![npm version](https://img.shields.io/npm/v/nodejs-store)
6
+ ![license](https://img.shields.io/npm/l/nodejs-store)
7
+ ![node](https://img.shields.io/node/v/nodejs-store)
8
+ ![backends](https://img.shields.io/badge/backends-MongoDB%20%7C%20MySQL%20%7C%20SQLite%20%7C%20PostgreSQL-blue)
9
+ ![query dialect](https://img.shields.io/badge/query%20dialect-GQL%20(MongoDB--flavoured)-green)
6
10
 
7
- ## Supported backends
11
+ `nodejs-store` lets a Node.js service talk to MongoDB (native aggregation), MySQL, PostgreSQL and SQLite through a **single schema definition and a single query dialect**. Nested relations compile to **one native query per backend** — you never hand-write `$lookup` or raw SQL.
8
12
 
9
- | Backend | Notes |
13
+ > Also looking for the Python version? See [`py-store`](https://github.com/coenddt/py-store) (pip `storepy`). Both are thin hosts over the shared Rust engine [`rust-store`](https://github.com/coenddt/rust-store).
14
+ > 中文文档见 [README.zh-CN.md](README.zh-CN.md)。
15
+
16
+ ---
17
+
18
+ ## Table of contents
19
+
20
+ - [What it is](#what-it-is)
21
+ - [When to use it](#when-to-use-it)
22
+ - [When not to use it](#when-not-to-use-it)
23
+ - [How it compares](#how-it-compares)
24
+ - [Installation](#installation)
25
+ - [Quick start](#quick-start)
26
+ - [Supported backends](#supported-backends)
27
+ - [Features](#features)
28
+ - [GQL tree queries](#gql-syntax)
29
+ - [Aggregation](#aggregation)
30
+ - [Query & write API](#query--write-api)
31
+ - [Multi-datasource connections](#multi-datasource-connections)
32
+ - [Permission context](#permission-context)
33
+ - [Schema reference](#schema-reference)
34
+ - [Advanced API](#advanced-api)
35
+ - [Transactions](#transaction-boundary)
36
+ - [FAQ](#faq)
37
+ - [Related projects](#related-projects)
38
+
39
+ ---
40
+
41
+ ## What it is
42
+
43
+ A lightweight, backend-agnostic data layer for Node.js. You describe your models once as pure JSON (`fields`, `relations`, `computes`, `indexes`, `read`/`write` role whitelists). From that description the library derives:
44
+
45
+ - **command planning** (GQL → Mongo command JSON) — executed by the Rust core `rust-store-node`,
46
+ - **dialect translation** (command JSON → parameterized SQL) for MySQL / PostgreSQL / SQLite,
47
+ - **permission checks** (schema-level + field-level read/write, owner-condition injection),
48
+ - **computed columns**, **soft-delete archives**, and **result rehydration** (flat JOIN rows → nested documents).
49
+
50
+ MongoDB is the *primary dialect*: queries are written in a MongoDB-flavoured GQL, and the three relational backends adapt to it. That is what makes one schema portable across a document store and three relational stores.
51
+
52
+ ### How it relates to py-store and rust-store
53
+
54
+ ```
55
+ ┌──────────────────────────────┐
56
+ Node.js ──▶ │ nodejs-store (npm, host) │ -┐
57
+ └──────────────────────────────┘ │ rust-store-node (napi-rs)
58
+ ▼
59
+ ┌───────────────────────────────┐
60
+ │ rust-store/core (pure logic) │
61
+ │ GQL · permissions · computes │
62
+ │ command planning · dialects │
63
+ └───────────────────────────────┘
64
+ ▲
65
+ ┌──────────────────────────────┐ │ rust-store-py (PyO3)
66
+ Python ──▶ │ py-store (pip, host) │ -┘
67
+ └──────────────────────────────┘
68
+ ```
69
+
70
+ The **Rust core** owns GQL parsing, permission checks, computed columns, command planning and SQL dialect translation — it never touches a database. The **hosts** (`nodejs-store`, `py-store`) own driver IO, callbacks and placeholder substitution. Behaviour therefore cannot drift between Node.js and Python: there is only one implementation.
71
+
72
+ ## When to use it
73
+
74
+ Reach for `nodejs-store` when any of these describe your situation:
75
+
76
+ - **One codebase, several databases.** You ship the same service against MongoDB in dev and PostgreSQL in production (or per-tenant), and you don't want two data-access layers.
77
+ - **You need nested / relational reads without writing `$lookup` or JOINs.** Order → items, Course → lessons, User → orders — all expressed once in the schema and resolved in a single query.
78
+ - **You are building an admin backend or internal CRUD service** and want schema-driven CRUD, soft-delete, computed columns and role checks without a full ORM.
79
+ - **You need row-level / field-level access control.** Whitelists per role, `guest` can never write, `creator` ownership is checked against `doc.createdBy`, and owner conditions are injected automatically into queries.
80
+ - **You are building an AI / natural-language data-QA layer.** The library was designed with AI query hosts in mind: `buildPipeline()` exposes the planned query without executing it, and degraded / non-pushdownable paths emit structured feedback events instead of failing silently. See the companion skill [`text-to-query`](#related-projects).
81
+ - **You are migrating between MongoDB and SQL** and want to keep one query syntax during the transition.
82
+ - **Multi-tenant SaaS.** One schema definition, N tenants: bind a schema to `(source, namespace, collection)` and re-target any query or write at execution time with a `{ source, namespace }` override.
83
+
84
+ Typical concrete scenarios (see [`doc/use-cases/`](doc/use-cases/) for full walkthroughs):
85
+
86
+ | Scenario | Why nodejs-store fits |
10
87
  | --- | --- |
11
- | MongoDB | native aggregation pipeline (`find`/`aggregate`/`$lookup`) |
12
- | MySQL | parameterized SQL, `information_schema` introspection |
13
- | SQLite | parameterized SQL, `sqlite_master` + `PRAGMA` introspection. **Sync driver** (`better-sqlite3`): calls block the event loop by design — for high-concurrency hot paths prefer MySQL/PostgreSQL/MongoDB, or isolate SQLite in a dedicated process |
14
- | PostgreSQL | parameterized SQL (`$n`), `RETURNING` support |
88
+ | Multi-tenant SaaS with per-tenant schema/database | `namespace` per tenant + runtime route override, one schema |
89
+ | Admin dashboard / internal tool | Schema-driven CRUD, soft-delete, computed columns, RBAC |
90
+ | MongoDB today, PostgreSQL tomorrow | Same GQL + same schema, only the datasource changes |
91
+ | AI data-QA / text-to-query agent | Plan-only `buildPipeline`, deterministic command JSON, feedback events |
92
+ | Mixed SQL + Mongo in one product | Cross-source queries with native SQL pushdown and Mongo in-memory federation |
93
+ | Audit-friendly CRUD | Every schema auto-gets a `<Model>Deleted` archive table/collection |
15
94
 
16
- GQL tree queries compile to a single native query per backend — never hand-write `$lookup` or raw SQL again.
95
+ ## When not to use it
17
96
 
18
- ## Features
97
+ Being explicit about the boundary saves you time:
19
98
 
20
- - **Pure JSON schemas, zero code** — a model is just an object: fields, relations, computes, indexes.
21
- - **Read-time defaults & computed columns** — writes store only user data; reads fill defaults and run `fn`/`asyncFn` computes.
22
- - **GQL tree queries → one native query** — nested relations resolve in a single query; never hand-write `$lookup` again.
23
- - **Smart mutation** — `mutation()` auto-detects upsert by `_id` + unique index and recursively fills relation children.
24
- - **Soft-delete built in** — every schema auto-registers a `<Model>Deleted` archive collection/table; `remove()` archives before deleting.
25
- - **Permission context** — `AsyncLocalStorage`-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
26
- - **Async-first** — built on the `mongodb` Node.js driver and a shared Rust core with SQL dialects.
99
+ - **You want a full ORM with a migration engine.** `nodejs-store` is a *data layer*, not a migration tool. It can **read** a SQL backend's physical structure (`syncSchema` → introspection) but it never writes DDL back. Pair it with your migration tool of choice.
100
+ - **You need a type-safe generated client.** Schemas are runtime JSON, not TypeScript types. You get flexibility and cross-language parity (same schema runs in Node and Python), not compile-time type inference.
101
+ - **You only ever use one database and rarely join.** A plain driver (or a single-database ODM/ORM) will be simpler.
102
+ - **You need raw aggregation escape hatches.** `$pipeline` passthrough and `store.aggregate()` were deliberately removed. Use `$condition` / `$group` / `$having` / relations; anything that cannot be safely translated fails **explicitly** rather than silently.
103
+ - **You are on SQLite in a high-concurrency hot path.** The SQLite executor uses the synchronous `better-sqlite3` driver by design — calls block the event loop. Prefer MySQL / PostgreSQL / MongoDB there, or isolate SQLite in its own process.
104
+
105
+ ## How it compares
106
+
107
+ General positioning, not a benchmark — always verify against each tool's current docs.
108
+
109
+ | | nodejs-store | Mongoose | Prisma | TypeORM / Sequelize | Drizzle |
110
+ | --- | --- | --- | --- | --- | --- |
111
+ | Primary shape | JSON schema + GQL data layer | ODM (MongoDB) | Schema DSL + generated client | Decorator/entity ORM | TypeScript SQL builder |
112
+ | Backends | MongoDB, MySQL, SQLite, PostgreSQL | MongoDB | PostgreSQL, MySQL, SQLite, SQL Server, MongoDB, CockroachDB | MySQL, PostgreSQL, SQLite, MSSQL, Oracle (+ MongoDB) | PostgreSQL, MySQL, SQLite, … |
113
+ | One query dialect across Mongo **and** SQL | ✅ (MongoDB-flavoured GQL) | ➖ (Mongo only) | ➖ (one client per provider) | ⚠️ (Mongo model differs from SQL entities) | ➖ (SQL only) |
114
+ | Nested relation reads in one query | ✅ declarative relations → `$lookup` / `JOIN` | ✅ `populate()` | ✅ `include` | ✅ relations | ⚠️ manual joins |
115
+ | Built-in role / field-level RBAC + owner injection | ✅ | ➖ | ➖ (via extensions) | ➖ | ➖ |
116
+ | Read-time computed columns (sync / async / relation-agg) | ✅ | ➖ (getters) | ➖ | ➖ | ➖ |
117
+ | Soft-delete archive table auto-provisioned | ✅ | ➖ | ➖ | ➖ | ➖ |
118
+ | Migration / DDL engine | ➖ (introspection read-only) | ➖ | ✅ | ✅ | ✅ |
119
+ | Static type generation | ➖ (runtime JSON, cross-language parity) | ➖ | ✅ | ⚠️ (decorators + TS) | ✅ |
120
+ | Shared native core across Node & Python | ✅ (Rust `rust-store`) | ➖ | ➖ | ➖ | ➖ |
121
+
122
+ ### How it differs from specific libraries
123
+
124
+ Positioning only, based on those projects' public documentation at the time of writing — verify against your own requirements.
125
+
126
+ - **vs Mongoose** — Mongoose is MongoDB-only. `nodejs-store` uses a similar MongoDB-style query syntax (`$gt`, `$or`, `$set`, `$inc`) but the same query also runs unchanged against MySQL, SQLite and PostgreSQL.
127
+ - **vs `mongoosql-core`** — the closest in spirit: it also runs Mongoose-style queries on MongoDB, PostgreSQL and MySQL. `nodejs-store` additionally targets SQLite, ships schema-level permissions (role/field whitelists plus `creator` owner-condition injection), read-time computed columns (`fn` / `asyncFn` / relation-`agg`), an auto-provisioned `<Model>Deleted` soft-delete archive, and shares one Rust engine with a Python host so Node.js and Python cannot drift apart.
128
+ - **vs `unsql`** — `unsql` generates SQL from plain JavaScript objects for MySQL, PostgreSQL and SQLite. It does not target MongoDB, and it is a query/CRUD helper rather than a schema-driven data layer with permissions and computed columns.
129
+ - **vs Prisma** — Prisma is a schema DSL plus generated client with a migration engine and compile-time types. `nodejs-store` is a runtime JSON schema with no DDL or migration responsibility (it only *reads* physical structure via introspection) and no type generation — in exchange for one query dialect spanning a document store and three relational stores.
130
+ - **vs TypeORM / Sequelize / Drizzle** — Sequelize and Drizzle are SQL-only; TypeORM models MongoDB separately from its SQL entities. `nodejs-store` treats MongoDB as the primary dialect and compiles the same GQL to SQL for the other three backends.
131
+
132
+ Short version: use an ORM when you want **compile-time types and migrations**; use `nodejs-store` when you want **one runtime schema + one query dialect spanning MongoDB and SQL**, with RBAC and computed columns built in.
27
133
 
28
134
  ## Installation
29
135
 
@@ -73,44 +179,35 @@ const items = await store.query(
73
179
  );
74
180
  ```
75
181
 
76
- ## Multi-datasource connections
77
-
78
- Every schema is located by the triple `(source, namespace, collection)` — the triple must be
79
- globally unique across the registry (duplicate registration throws instead of silently
80
- mis-routing).
81
-
82
- - `source` — connection key in `init({...})` (default `"default"`).
83
- - `namespace` — database/schema inside the connection: Mongo db name, PG schema,
84
- MySQL database, SQLite attached db. Optional; `null` = connection default.
85
- - `collection` — table/collection name.
182
+ The same schema and the same query run unchanged against PostgreSQL — only the `init()` datasource changes:
86
183
 
87
184
  ```js
88
- // Multiple Mongo servers: one source per connection
89
- await init({ mongo_main: db, pg_a: { kind: 'postgres', exec } });
90
-
91
- // Same MongoClient serving multiple databases: declare namespace (db name)
92
- await init({ cluster: client });
93
- store.register({ name: 'User', collection: 'users', datasource: 'cluster', namespace: 'tenant_42', ... });
94
-
95
- // SQL cross-namespace joins are pushed down natively ("ns_a"."t" JOIN "ns_b"."t");
96
- // only Mongo cross-db relations fall back to in-memory federation.
185
+ await init({ default: { kind: 'postgres', exec } }); // exec: your pg pool adapter
186
+ const items = await store.query('Post($condition:@c0) { title, status }', { c0: { status: 'draft' } });
97
187
  ```
98
188
 
99
- **Multi-tenant route override** — one schema definition, N tenants. Any query/write accepts
100
- a `{ source, namespace }` override that re-targets commands at execution time (permissions
101
- and computed columns still follow the structural schema):
189
+ ## Supported backends
102
190
 
103
- ```js
104
- await store.query('User($condition:@c0){...}', params, { namespace: 'tenant_42' });
105
- await store.insert('Order', data, { source: 'pg_cluster', namespace: 'tenant_7' });
106
- ```
191
+ | Backend | Notes |
192
+ | --- | --- |
193
+ | MongoDB | native aggregation pipeline (`find`/`aggregate`/`$lookup`) |
194
+ | MySQL | parameterized SQL, `information_schema` introspection |
195
+ | SQLite | parameterized SQL, `sqlite_master` + `PRAGMA` introspection. **Sync driver** (`better-sqlite3`): calls block the event loop by design — for high-concurrency hot paths prefer MySQL/PostgreSQL/MongoDB, or isolate SQLite in a dedicated process |
196
+ | PostgreSQL | parameterized SQL (`$n`), `RETURNING` support |
107
197
 
108
- **`routeOverride` is a trusted server-side parameter** — it carries no origin check, so
109
- forwarding user-controlled input into it lets a caller re-target another tenant's
110
- `source`/`namespace` (CWE-639 authorization-bypass surface). Never pass raw request data here.
198
+ GQL tree queries compile to a single native query per backend — never hand-write `$lookup` or raw SQL again.
111
199
 
112
- Legacy single-db usage (`init(db)` + schema without `datasource`/`namespace`) is unchanged:
113
- commands carry `source: 'default'`, `namespace: null`.
200
+ ## Features
201
+
202
+ - **Pure JSON schemas, zero code** — a model is just an object: fields, relations, computes, indexes.
203
+ - **GQL tree queries → one native query** — nested relations resolve in a single query; never hand-write `$lookup` again.
204
+ - **Normalized aggregation** — root-level `$group` / `$having` and relation aggregate predicates (semi/anti-join) in the same GQL, pushed down to all four backends.
205
+ - **Read-time defaults & computed columns** — writes store only user data; reads fill defaults and run `fn` / `asyncFn` / relation-`agg` computes.
206
+ - **Smart mutation** — `mutation()` auto-detects upsert by `_id` + unique index and recursively fills relation children.
207
+ - **Soft-delete built in** — every schema auto-registers a `<Model>Deleted` archive collection/table; `remove()` archives before deleting.
208
+ - **Permission context** — `AsyncLocalStorage`-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
209
+ - **Multi-datasource & multi-tenant** — locate a schema by `(source, namespace, collection)`; re-target per request with a route override.
210
+ - **Async-first, Rust core** — built on the `mongodb` Node.js driver and a shared Rust core with SQL dialects.
114
211
 
115
212
  ## GQL syntax
116
213
 
@@ -123,11 +220,59 @@ Model($condition:@c0,$sort:@s1,$skip:@sk,$limit:@l1) {
123
220
 
124
221
  - Values come from the params object: `{ c0: {...}, s1: {...} }`.
125
222
  - Object sub-fields use dot notation; relations are declared in the schema (`type: 'many' | 'one'`) and resolved automatically — **do not hand-write `$lookup`**.
223
+ - `many` relations return arrays (`[]` when empty); `one` relations merge into the parent document (`null` when missing).
224
+ - Relation-level `$sort`/`$skip`/`$limit` are **per-parent top-N** (each parent gets its own window; translated to a window function on SQL).
225
+
226
+ > **Breaking change**: user `$pipeline` passthrough and `store.aggregate()` were removed (raw aggregation escape hatch). A GQL containing `$pipeline` now fails explicitly instead of being silently ignored.
227
+
228
+ ## Aggregation
229
+
230
+ Normalized aggregation lives **inside GQL** — no separate API, no raw pipeline.
231
+
232
+ **Root-level `$group` + `$having`** (GROUP BY / HAVING):
233
+
234
+ ```js
235
+ const rows = await store.query(
236
+ 'Course($condition:@c0,$group:@g0,$having:@h0,$sort:@s0,$limit:@l0){ status, n, total }',
237
+ {
238
+ c0: { status: { $ne: 'deleted' } },
239
+ g0: { by: ['status'], agg: { n: { $count: '*' }, total: { $sum: 'price' } } },
240
+ h0: { n: { $gt: 1 } },
241
+ s0: { total: -1 },
242
+ l0: 20,
243
+ },
244
+ );
245
+ ```
246
+
247
+ - Whitelisted operators: `$count` / `$sum` / `$avg` / `$min` / `$max`.
248
+ - Fixed execution order: `$condition` (WHERE) → `$group` (GROUP BY) → `$having` (HAVING) → `$sort` → `$skip`/`$limit` → projection.
249
+ - Omit `by` (or pass `[]`) for a single all-table group; the empty-input case still returns one row (`$count` → `0`, others → `null`).
250
+
251
+ **Relation aggregate predicates (semi / anti-join)** — filter parents by an aggregate over a relation, without fanning out:
252
+
253
+ ```js
254
+ await store.query('Product($condition:@c0,$sort:@s0){ _id, name }', {
255
+ c0: {
256
+ $and: [
257
+ { status: 'onSale' },
258
+ { orders: { $count: { $gt: 3 } } }, // has > 3 orders
259
+ { $not: { orders: { $sum: { $of: 'amount', $gt: 10000 } } } }, // not a whale
260
+ ],
261
+ },
262
+ s0: { name: 1 },
263
+ });
264
+ ```
265
+
266
+ Translates to `EXISTS` / `NOT EXISTS` on SQL and `$lookup` + `$match` on MongoDB.
126
267
 
127
- > **Breaking change**: user `$pipeline` passthrough and `store.aggregate()` were removed
128
- > (raw aggregation escape hatch). A GQL containing `$pipeline` now fails explicitly instead
129
- > of being silently ignored. Use `$condition`/`$sort`/`$skip`/`$limit` + relations; normalized
130
- > aggregation (`$group`/`$sum`) is being redesigned and will return under a single GQL syntax.
268
+ **Relation-rolling computed columns** — declare once in the schema, request by name:
269
+
270
+ ```js
271
+ computes: {
272
+ itemCount: { type: 'int', agg: { $count: 'items' } }, // 0 when empty
273
+ itemsTotal: { type: 'float', agg: { $sum: 'items.qty' } }, // null when empty
274
+ }
275
+ ```
131
276
 
132
277
  ## Query & write API
133
278
 
@@ -153,6 +298,46 @@ Notes:
153
298
  - `null`/`undefined` values are stripped before persisting; `_id` cannot be changed via `update`.
154
299
  - `createdAt`/`updatedAt` (ms) are framework-maintained — do not set them manually.
155
300
  - `queryWithCount` accepts `page`/`pageSize` (recommended) or the traditional `$skip`/`$limit` params.
301
+ - `updateMany` / `remove` with an **empty condition** (`{}`, `null`, `{ "$and": [] }`) is rejected outright — it never falls through to a full-table write.
302
+
303
+ ## Multi-datasource connections
304
+
305
+ Every schema is located by the triple `(source, namespace, collection)` — the triple must be
306
+ globally unique across the registry (duplicate registration throws instead of silently
307
+ mis-routing).
308
+
309
+ - `source` — connection key in `init({...})` (default `"default"`).
310
+ - `namespace` — database/schema inside the connection: Mongo db name, PG schema,
311
+ MySQL database, SQLite attached db. Optional; `null` = connection default.
312
+ - `collection` — table/collection name.
313
+
314
+ ```js
315
+ // Multiple Mongo servers: one source per connection
316
+ await init({ mongo_main: db, pg_a: { kind: 'postgres', exec } });
317
+
318
+ // Same MongoClient serving multiple databases: declare namespace (db name)
319
+ await init({ cluster: client });
320
+ store.register({ name: 'User', collection: 'users', datasource: 'cluster', namespace: 'tenant_42', ... });
321
+
322
+ // SQL cross-namespace joins are pushed down natively ("ns_a"."t" JOIN "ns_b"."t");
323
+ // only Mongo cross-db relations fall back to in-memory federation.
324
+ ```
325
+
326
+ **Multi-tenant route override** — one schema definition, N tenants. Any query/write accepts
327
+ a `{ source, namespace }` override that re-targets commands at execution time (permissions
328
+ and computed columns still follow the structural schema):
329
+
330
+ ```js
331
+ await store.query('User($condition:@c0){...}', params, { namespace: 'tenant_42' });
332
+ await store.insert('Order', data, { source: 'pg_cluster', namespace: 'tenant_7' });
333
+ ```
334
+
335
+ **`routeOverride` is a trusted server-side parameter** — it carries no origin check, so
336
+ forwarding user-controlled input into it lets a caller re-target another tenant's
337
+ `source`/`namespace` (CWE-639 authorization-bypass surface). Never pass raw request data here.
338
+
339
+ Legacy single-db usage (`init(db)` + schema without `datasource`/`namespace`) is unchanged:
340
+ commands carry `source: 'default'`, `namespace: null`.
156
341
 
157
342
  ## Permission context
158
343
 
@@ -219,6 +404,12 @@ a missing context and always passes. `setRequireContext(false)` restores the def
219
404
 
220
405
  Types: `string | int | long | float | double | boolean | array | object | date | any`.
221
406
 
407
+ Boundary rules worth knowing up front (all **fail explicitly**, never silently degrade):
408
+
409
+ - Filtering on array fields directly, on a whole object field, or on object dot-paths is rejected on every backend — model cross-entity semantics as `relations` instead.
410
+ - Relation predicates support **one level** of relation; paths like `orders.items.price` are rejected.
411
+ - An unreadable relation is an error, not a silent `false`.
412
+
222
413
  ## Advanced API
223
414
 
224
415
  Everything below is reachable from the exported `store` singleton or the modules it
@@ -228,7 +419,8 @@ re-exports. Options prefixed with `?` are optional.
228
419
 
229
420
  Low-level parse — compiles GQL to the command plan **without executing it**, returning
230
421
  `{ tokens, ast, pipeline, projection }`. Useful for debugging query shape, asserting
231
- pushdown behaviour, or building custom tooling. Permissions / computes are **not** applied here.
422
+ pushdown behaviour, or building custom tooling (e.g. an AI query agent that must show and
423
+ validate a plan before running it). Permissions / computes are **not** applied here.
232
424
 
233
425
  ```js
234
426
  const plan = store.buildPipeline('Post($condition:@c0){ title }', { c0: { status: 'draft' } });
@@ -273,6 +465,9 @@ store.setFeedbackSink((e) => logger.warn({ code: e.code }, e.hint));
273
465
  // layer federation | dialect | ...
274
466
  ```
275
467
 
468
+ Non-pushdownable commands also throw `PushdownUnsupportedError` — catch it to re-run that
469
+ segment against a Mongo source.
470
+
276
471
  ### Low-level modules
277
472
 
278
473
  The package re-exports its building blocks for advanced hosts:
@@ -307,6 +502,47 @@ await init({ default: db, pg_a: executors.createConnection('postgres', pgPool) }
307
502
  - **Archive idempotency**: `remove` archives with upsert-by-`_id` semantics, so a retry after partial failure no longer fails on duplicate `_id`.
308
503
  - **Cross-source steps** (parent and child bound to different datasources) cannot be atomic — they run sequentially by design.
309
504
 
505
+ ## FAQ
506
+
507
+ **How do I use one schema for both MongoDB and PostgreSQL in Node.js?**
508
+ Define the schema once as JSON, call `init()` with your datasource(s), and run the same GQL against either. MongoDB uses native aggregation; MySQL/PostgreSQL/SQLite get parameterized SQL. See [Quick start](#quick-start).
509
+
510
+ **How do I query nested / related data without writing `$lookup` or JOINs?**
511
+ Declare the relation in `relations` (`{ model, type: 'many' | 'one', localField, foreignField }`) and reference the relation name inside the GQL selection set. It becomes `$lookup` on Mongo and a `JOIN` on SQL, returned as nested documents.
512
+
513
+ **Does it support GROUP BY / COUNT / SUM / AVG?**
514
+ Yes — normalized aggregation is part of GQL: root-level `$group` / `$having` and relation aggregate predicates. See [Aggregation](#aggregation).
515
+
516
+ **Can I filter parents by an aggregate of their children ("products with more than 3 orders")?**
517
+ Yes — relation aggregate predicates implement semi/anti-join without fanning out; SQL uses `EXISTS`/`NOT EXISTS`.
518
+
519
+ **How do I implement row-level permissions?**
520
+ Use `store.setContext({ userId, roles })` plus schema-level `read`/`write` whitelists. The `creator` pseudo-role adds automatic ownership checks and owner-condition injection. `guest` can never write. Turn on `setRequireContext(true)` for fail-secure behaviour.
521
+
522
+ **How do I do soft delete?**
523
+ Every registered model automatically gets a `<Model>Deleted` archive collection/table. `store.remove()` archives the document first, then deletes it; re-creating the same `_id` does not collide because the archive write is upsert-by-`_id`.
524
+
525
+ **Is it usable for multi-tenant applications?**
526
+ Yes. Bind a schema to `(source, namespace, collection)` and pass a `{ source, namespace }` route override per request. Treat `routeOverride` as trusted server-side input only.
527
+
528
+ **Does it run migrations?**
529
+ No. `syncSchema()` only *reads* physical structure via introspection (introspect → merge overlay → register). Schema changes / DDL are your migration tool's job.
530
+
531
+ **Can I see the generated query without running it?**
532
+ Yes — `store.buildPipeline(gql, params)` returns the compiled plan (`{ tokens, ast, pipeline, projection }`) with no execution and no permission/compute application.
533
+
534
+ **What happens when SQL pushdown isn't possible?**
535
+ The command throws `PushdownUnsupportedError` **and** emits a structured feedback event (`sql_pushdown_unsupported`) through `setFeedbackSink`. Cross-source pagination/sort degradations emit `federation_degraded` events. Nothing fails silently.
536
+
537
+ **How is it related to py-store and rust-store?**
538
+ `rust-store` is the shared Rust engine (GQL parsing, permissions, computed columns, command planning, SQL dialect translation — pure logic, no IO). `nodejs-store` (npm) and [`py-store`](https://github.com/coenddt/py-store) (pip `storepy`) are thin hosts in front of it: they own driver IO, callbacks and placeholder substitution. Same schemas, same GQL, same semantics in Node and Python.
539
+
540
+ ## Related projects
541
+
542
+ - [`py-store`](https://github.com/coenddt/py-store) — the Python asyncio twin (pip `storepy`, `from py_store import init, store`).
543
+ - [`rust-store`](https://github.com/coenddt/rust-store) — the shared Rust core and its `rust-store-node` / `rust-store-py` bindings.
544
+ - `text-to-query` — a companion skill that turns natural-language questions into GQL + params for this data layer.
545
+
310
546
  ## License
311
547
 
312
548
  [MIT](LICENSE)