nodejs-store 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nodejs-store contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # nodejs-store
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**.
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).
6
+
7
+ ## Supported backends
8
+
9
+ | Backend | Notes |
10
+ | --- | --- |
11
+ | MongoDB | native aggregation pipeline (`find`/`aggregate`/`$lookup`) |
12
+ | MySQL | parameterized SQL, `information_schema` introspection |
13
+ | SQLite | parameterized SQL, `sqlite_master` + `PRAGMA` introspection |
14
+ | PostgreSQL | parameterized SQL (`$n`), `RETURNING` support |
15
+
16
+ GQL tree queries compile to a single native query per backend — never hand-write `$lookup` or raw SQL again.
17
+
18
+ ## Features
19
+
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.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ npm install nodejs-store
32
+ ```
33
+
34
+ Requires Node.js 18+ and one supported backend (MongoDB / MySQL / SQLite / PostgreSQL).
35
+
36
+ ## Quick start
37
+
38
+ ```js
39
+ const { MongoClient } = require('mongodb');
40
+ const { init, store } = require('nodejs-store');
41
+
42
+ const client = new MongoClient('mongodb://localhost:27017');
43
+ await client.connect();
44
+ await init(client.db('mydb')); // idempotently creates indexes for registered schemas
45
+
46
+ // Register a schema (pure JSON)
47
+ store.register({
48
+ name: 'Post', // model name used in GQL
49
+ collection: 'posts', // optional, defaults to name
50
+ idPrefix: 'PT', // string _id: prefix + base36 timestamp + random
51
+ fields: {
52
+ title: { type: 'string', default: '' },
53
+ status: { type: 'string', default: 'draft' },
54
+ tags: { type: 'array', default: [] },
55
+ },
56
+ computes: {
57
+ statusLabel: {
58
+ type: 'string',
59
+ depends: ['status'],
60
+ fn: (doc) => (doc.status || '').toUpperCase(),
61
+ },
62
+ },
63
+ indexes: [{ keys: { status: 1, createdAt: -1 } }],
64
+ });
65
+
66
+ // Write — only user data; defaults are filled on read
67
+ const doc = await store.insert('Post', { title: 'Hello' });
68
+
69
+ // Query — GQL tree syntax, values referenced from params via @key
70
+ const items = await store.query(
71
+ 'Post($condition:@c0,$sort:@s1,$limit:@l) { title, status, statusLabel }',
72
+ { c0: { status: 'draft' }, s1: { createdAt: -1 }, l: 20 },
73
+ );
74
+ ```
75
+
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.
86
+
87
+ ```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.
97
+ ```
98
+
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):
102
+
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
+ ```
107
+
108
+ Legacy single-db usage (`init(db)` + schema without `datasource`/`namespace`) is unchanged:
109
+ commands carry `source: 'default'`, `namespace: null`.
110
+
111
+ ## GQL syntax
112
+
113
+ ```text
114
+ Model($condition:@c0,$sort:@s1,$skip:@sk,$limit:@l1) {
115
+ field1, field2, obj.subField,
116
+ Relation($condition:@c2,$sort:@s3,$limit:@l2) { f3, Nested { f4 } }
117
+ }
118
+ ```
119
+
120
+ - Values come from the params object: `{ c0: {...}, s1: {...} }`.
121
+ - Object sub-fields use dot notation; relations are declared in the schema (`type: 'many' | 'one'`) and resolved automatically — **do not hand-write `$lookup`**.
122
+ - `$pipeline` passes a raw aggregation through as-is (no compute/defaults/permission trimming) — use with care; prefer `store.aggregate(model, pipeline)` for group/sum needs.
123
+
124
+ ## Query & write API
125
+
126
+ ```js
127
+ const items = await store.query(gql, params); // Array
128
+ const one = await store.queryOne(gql, params); // object | null
129
+ const page = await store.queryWithCount(gql, params); // { items, total, hasMore, page, pageSize } (pageSize capped at 5000)
130
+ const exists = await store.exists('Post', { _id: pid });
131
+ const n = await store.count('Post', { status: 'active' });
132
+
133
+ const doc = await store.insert('Post', { ... }); // auto _id / createdAt / updatedAt
134
+ const docs = await store.insertMany('Post', [{ ... }, ...]);
135
+ await store.update('Post', { _id: pid }, { status: 'live' }); // plain fields → $set
136
+ await store.update('Post', { _id: pid }, { $inc: { views: 1 } }); // '$'-prefixed keys pass through as operators
137
+ await store.updateMany('Post', { type: t }, { status: 'live' });
138
+ const r = await store.remove('Post', { _id: pid }); // archives to <collection>_deleted first
139
+ await store.mutation('Post', { ... }); // smart upsert + recursive relation children
140
+ await store.upsert('Post', { code: 'A1' }, { ... }); // explicit-condition upsert (no relation handling)
141
+ const rows = await store.aggregate('Post', pipeline); // native aggregation
142
+ ```
143
+
144
+ Notes:
145
+
146
+ - `null`/`undefined` values are stripped before persisting; `_id` cannot be changed via `update`.
147
+ - `createdAt`/`updatedAt` (ms) are framework-maintained — do not set them manually.
148
+ - `queryWithCount` accepts `page`/`pageSize` (recommended) or the traditional `$skip`/`$limit` params.
149
+
150
+ ## Permission context
151
+
152
+ ```js
153
+ // Set once per request (in middleware/router layer)
154
+ store.setContext({ userId: uid, roles: ['editor'] });
155
+
156
+ // Nested-safe role scoping
157
+ store.scopedRoles(['viewer'], () => store.query(gql, params));
158
+
159
+ // Internal/cron jobs — bypass permission checks
160
+ await store.runAsInternal(() => store.remove('Post', { _id: pid }));
161
+ ```
162
+
163
+ - `super_admin`/`admin`/`internal` roles pass everything; other roles are checked against schema-level and field-level `read`/`write` whitelists; `guest` can never write.
164
+ - `creator` is a pseudo-role resolved by `doc.createdBy === ctx.userId`; schemas granting it automatically get owner conditions injected on queries and ownership checks on update/remove.
165
+ - No context set → permission checks disabled (backward compatible).
166
+ - Denied access throws `store.PermissionError` (with `status = 403`).
167
+
168
+ ## Schema reference
169
+
170
+ ```js
171
+ {
172
+ name: 'Order',
173
+ collection: 'orders',
174
+ idPrefix: 'OD',
175
+ timestamps: true, // default: auto-maintain createdAt/updatedAt (ms)
176
+ fields: {
177
+ _id: 'string', // shorthand
178
+ title: { type: 'string', default: '' },
179
+ meta: { type: 'object', default: {}, fields: { ... } }, // nested object fields
180
+ },
181
+ relations: {
182
+ items: { model: 'OrderItem', type: 'many', localField: '_id', foreignField: 'orderId' },
183
+ },
184
+ computes: {
185
+ total: { type: 'float', depends: ['amount'], fn: (d) => d.amount * 1.1 },
186
+ itemCount: { type: 'int', lookup: { $size: { $ifNull: ['$items', []] } } },
187
+ },
188
+ indexes: [
189
+ { keys: { status: 1 } },
190
+ { keys: { code: 1 }, options: { unique: true } },
191
+ ],
192
+ read: ['editor', 'viewer'], // optional schema-level role whitelists
193
+ write: ['editor'],
194
+ }
195
+ ```
196
+
197
+ Types: `string | int | long | float | double | boolean | array | object | date | any`.
198
+
199
+ ## License
200
+
201
+ [MIT](LICENSE)
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "nodejs-store",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight multi-backend data layer (MongoDB / MySQL / SQLite / PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single query, computed columns, soft-delete and role-based access control",
5
+ "main": "src/index.js",
6
+ "files": [
7
+ "src",
8
+ "README.md",
9
+ "LICENSE"
10
+ ],
11
+ "scripts": {
12
+ "test": "node scripts/test.js"
13
+ },
14
+ "keywords": [
15
+ "mongodb",
16
+ "mysql",
17
+ "sqlite",
18
+ "postgresql",
19
+ "data-layer",
20
+ "query-builder",
21
+ "odm",
22
+ "gql",
23
+ "acl",
24
+ "aggregation"
25
+ ],
26
+ "author": "leo <coen_ddt@qq.com>",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/coenddt/nodejs-store.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/coenddt/nodejs-store/issues"
34
+ },
35
+ "homepage": "https://github.com/coenddt/nodejs-store#readme",
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "dependencies": {
40
+ "better-sqlite3": "^13.0.3",
41
+ "mongodb": "^6.21.0",
42
+ "mysql2": "^3.24.4",
43
+ "pg": "^8.23.0",
44
+ "rust-store-node": "^1.0.0"
45
+ }
46
+ }
package/src/core.js ADDED
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * rust-store 原生绑定加载器(唯一原生模块入口)
5
+ *
6
+ * mongo-store 采用「Rust 单核心 + 双绑定」架构:schema/GQL/权限/计算列/命令规划
7
+ * 全部在 Rust core 实现,本目录 src/*.js 只是薄 Host 适配层(驱动 IO + 回调 + 占位符)。
8
+ *
9
+ * 生产环境**只**从 npm 依赖 `rust-store-node` 加载原生模块。
10
+ * 开发期若需从相邻 rust-store 仓库的调试产物加载,必须显式设置:
11
+ * LOCAL_CORE=1 且 NODE_ENV !== 'production'
12
+ */
13
+
14
+ const path = require('path');
15
+
16
+ function _requireDevFallback() {
17
+ // 开发期兜底:仅 LOCAL_CORE=1 且非 production 时启用,
18
+ // 避免生产环境从相邻目录加载任意原生模块。
19
+ if (process.env.LOCAL_CORE !== '1' || process.env.NODE_ENV === 'production') return null;
20
+ const devPath = path.join(
21
+ __dirname,
22
+ '..',
23
+ '..',
24
+ 'rust-store',
25
+ 'core-node',
26
+ 'dist',
27
+ 'rust-store-node.node',
28
+ );
29
+ try {
30
+ return require(devPath);
31
+ } catch (e) {
32
+ return { __error: `${devPath}: ${e.message.split('\n')[0]}` };
33
+ }
34
+ }
35
+
36
+ function _load() {
37
+ try {
38
+ return require('rust-store-node');
39
+ } catch (e) {
40
+ const fallback = _requireDevFallback();
41
+ if (fallback && !fallback.__error) return fallback;
42
+
43
+ const hints = [`rust-store-node: ${e.message.split('\n')[0]}`];
44
+ if (fallback && fallback.__error) hints.push(fallback.__error);
45
+ throw new Error(
46
+ '无法加载 rust-store 原生核心(rust-store-node 绑定产物)。\n'
47
+ + '请安装 npm 依赖 rust-store-node;开发期如需从相邻 rust-store 仓库加载,\n'
48
+ + '请设置 LOCAL_CORE=1(且 NODE_ENV !== \'production\')并在 rust-store 仓库构建:\n'
49
+ + ' cargo build --manifest-path core-node/Cargo.toml\n'
50
+ + hints.join('\n'),
51
+ );
52
+ }
53
+ }
54
+
55
+ module.exports = _load();
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 命令执行(唯一 IO 边界) + 占位符替换 + core 调用包装
5
+ *
6
+ * 全部纯逻辑(GQL 解析、权限、命令规划、结果后处理)都在 Rust core;
7
+ * 本模块只做 Host 三件事里最底层的一件:把 core 产出的 Command JSON
8
+ * 路由到对应数据源连接并执行。不确定性输入由本层供给(now 时钟)。
9
+ *
10
+ * 路由规则见 `../datasource`:命令自带 `source` / `namespace` 三元组,按 `source`
11
+ * 选连接、`namespace` 定位连接内的库(Mongo 双形态严格校验),
12
+ * Mongo 走原生驱动,SQL 走 `translate → exec`。
13
+ */
14
+
15
+ const { PermissionError, getContext } = require('../permission');
16
+ const datasource = require('../datasource');
17
+
18
+ const _PHASE1_IDS = /^\{\{phase1\.ids\}\}$/;
19
+ const _STEP_PH = /^\{\{step\.(\d+)\._id\}\}$/;
20
+
21
+ /** core 权限类错误消息 → PermissionError(消息与 core 常量保持一致) */
22
+ const _PERMISSION_MSGS = new Set(['无访问权限', '无写入权限', '无删除权限', '无批量写入权限']);
23
+
24
+ /** 设置数据源连接映射(对 `../datasource` 的路由入口做包内透出) */
25
+ const setConnections = datasource.setConnections;
26
+
27
+ /** 毫秒时间戳(Host 时钟源) */
28
+ function _now() {
29
+ return Date.now();
30
+ }
31
+
32
+ function _ctx() {
33
+ return getContext() ?? null;
34
+ }
35
+
36
+ /** 绑定层调用包装:权限类错误映射为 PermissionError */
37
+ function _call(fn) {
38
+ try {
39
+ return fn();
40
+ } catch (e) {
41
+ if (_PERMISSION_MSGS.has(e && e.message)) throw new PermissionError(e.message);
42
+ throw e;
43
+ }
44
+ }
45
+
46
+ // ─── 命令执行(唯一 IO 边界) ────────────────────────────────
47
+
48
+ /** Command JSON → MongoDB 原生驱动调用 */
49
+ async function _execMongo(db, cmd) {
50
+ const coll = db.collection(cmd.collection);
51
+ switch (cmd.kind) {
52
+ case 'find': {
53
+ const opts = cmd.projection ? { projection: cmd.projection } : undefined;
54
+ return coll.find(cmd.filter, opts).toArray();
55
+ }
56
+ case 'aggregate':
57
+ return coll.aggregate(cmd.pipeline).toArray();
58
+ case 'countDocuments':
59
+ return coll.countDocuments(cmd.filter);
60
+ case 'findOne': {
61
+ const opts = cmd.projection ? { projection: cmd.projection } : undefined;
62
+ return coll.findOne(cmd.filter, opts);
63
+ }
64
+ case 'insertOne':
65
+ await coll.insertOne(cmd.doc);
66
+ return cmd.doc;
67
+ case 'insertMany':
68
+ await coll.insertMany(cmd.docs);
69
+ return { insertedCount: cmd.docs.length };
70
+ case 'findOneAndUpdate':
71
+ return coll.findOneAndUpdate(cmd.filter, cmd.update, cmd.options);
72
+ case 'updateMany':
73
+ return coll.updateMany(cmd.filter, cmd.update);
74
+ case 'deleteMany':
75
+ return coll.deleteMany(cmd.filter);
76
+ default:
77
+ throw new Error(`未支持的命令: ${cmd.kind}`);
78
+ }
79
+ }
80
+
81
+ /** 在指定数据源上执行命令(Mongo 走原生驱动,SQL 走 translate → exec) */
82
+ async function _execOn(source, cmd) {
83
+ const connection = datasource.getConnection(source);
84
+ const db = datasource.mongoDb(connection, source, cmd.namespace ?? null);
85
+ if (db) {
86
+ return _execMongo(db, cmd);
87
+ }
88
+ return datasource.execSql(source, connection, cmd);
89
+ }
90
+
91
+ /** Command JSON → 按命令自带的 `source` 路由(不按 collection 反查) */
92
+ async function _exec(cmd) {
93
+ return _execOn(cmd.source || datasource.DEFAULT_SOURCE, cmd);
94
+ }
95
+
96
+ /** 深度替换命令中的占位符(命中 resolver 返回非字符串时替换) */
97
+ function _substitute(value, resolver) {
98
+ if (typeof value === 'string') return resolver(value);
99
+ if (Array.isArray(value)) return value.map((v) => _substitute(v, resolver));
100
+ if (value && typeof value === 'object') {
101
+ const out = {};
102
+ for (const [k, v] of Object.entries(value)) out[k] = _substitute(v, resolver);
103
+ return out;
104
+ }
105
+ return value;
106
+ }
107
+
108
+ /**
109
+ * Host 契约:把命令中的占位符替换为执行结果
110
+ *
111
+ * - `{{phase1.ids}}` → 两阶段查询第一步取回的 id 数组(整值替换)
112
+ * - `{{step.<N>._id}}` → mutation 第 N 步执行结果的 _id
113
+ *
114
+ * 未命中的占位符原样保留(便于定位 core 与 Host 的契约漂移)。
115
+ * Python 侧 `py_store.crud.exec.resolve_placeholders` 为同语义实现,
116
+ * 两侧共测 `rust-store/fixtures/host/placeholders.json`。
117
+ */
118
+ function resolvePlaceholders(command, { ids = null, steps = [] } = {}) {
119
+ return _substitute(command, (s) => {
120
+ if (_PHASE1_IDS.test(s)) return ids ?? s;
121
+ const m = s.match(_STEP_PH);
122
+ if (m) {
123
+ const idx = Number(m[1]);
124
+ if (idx < steps.length) return steps[idx];
125
+ }
126
+ return s;
127
+ });
128
+ }
129
+
130
+ module.exports = {
131
+ setConnections,
132
+ _now,
133
+ _ctx,
134
+ _call,
135
+ _exec,
136
+ _execOn,
137
+ _substitute,
138
+ resolvePlaceholders,
139
+ };
package/src/crud/id.js ADDED
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ID 供给(Host 随机源) —— 与 core `needs_new_id` 语义对齐
5
+ *
6
+ * core 无随机源:需要新 _id 时由 Host 按序供给,本模块负责生成与遍历。
7
+ */
8
+
9
+ const { get: _getSchema } = require('../schema');
10
+
11
+ const _ID_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
12
+
13
+ /** 按 schema.idPrefix 生成唯一 ID(时间戳36进制 + 随机4位) */
14
+ function _generateId(schema) {
15
+ const ts = Date.now().toString(36).toUpperCase();
16
+ let rnd = '';
17
+ for (let i = 0; i < 4; i++) {
18
+ rnd += _ID_CHARS[Math.floor(Math.random() * _ID_CHARS.length)];
19
+ }
20
+ return schema.idPrefix + ts + rnd.toUpperCase();
21
+ }
22
+
23
+ /** 对齐 core `is_truthy`(字符串仅判空,不 trim) */
24
+ function _truthy(v) {
25
+ if (v === null || v === undefined || v === false) return false;
26
+ if (typeof v === 'number') return v !== 0;
27
+ if (typeof v === 'string') return v.length > 0;
28
+ return true;
29
+ }
30
+
31
+ /**
32
+ * 预生成 mutation 的 ID 池:按数据树逐节点判断是否需要新 _id
33
+ * (与 core `needs_new_id` 一致:无有效 _id 且 schema 配了 idPrefix),
34
+ * 保证游标消费顺序与节点顺序对齐(父子 schema 前缀不同也能取对 ID)。
35
+ */
36
+ function _newIdPool(schemaName, data) {
37
+ const pool = [];
38
+ const walk = (name, node) => {
39
+ const s = _getSchema(name);
40
+ if (!_truthy(node?._id) && s.idPrefix) {
41
+ pool.push(_generateId(s));
42
+ }
43
+ for (const [key, val] of Object.entries(node || {})) {
44
+ const rel = s.relations[key];
45
+ if (!rel || val === null || val === undefined) continue;
46
+ if (Array.isArray(val)) {
47
+ for (const child of val) {
48
+ if (child !== null && child !== undefined) walk(rel.model, child);
49
+ }
50
+ } else {
51
+ walk(rel.model, val);
52
+ }
53
+ }
54
+ };
55
+ walk(schemaName, data);
56
+ return pool;
57
+ }
58
+
59
+ module.exports = { _generateId, _truthy, _newIdPool };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CRUD 包 —— 薄 Host 适配层(对齐 py-store/src/py_store/crud/ 的分工)
5
+ *
6
+ * 全部纯逻辑(GQL 解析、权限、命令规划、结果后处理)都在 Rust core;
7
+ * 本包只做 Host 三件事:
8
+ * 1. 命令执行(唯一 IO 边界:按 collection 绑定路由到 Mongo / SQL,见 `../datasource`)
9
+ * 2. 占位符替换({{phase1.ids}} / {{step.<N>._id}} 依赖真实执行结果)
10
+ * 3. 原生回调(asyncFn 计算列两段式:prepareQuery 取 fnRefs → Host await → stripQuery)
11
+ *
12
+ * 不确定性输入由本包供给:now(时钟)、newIds(随机 ID,core 按需消费)。
13
+ *
14
+ * 模块划分:
15
+ * - [`exec`]:命令执行 + 占位符替换 + core 调用包装(唯一 IO 边界)
16
+ * - [`id`]:ID 生成与 mutation ID 池遍历
17
+ * - [`query`]:读路径
18
+ * - [`write`]:写路径
19
+ * - [`mutation`]:mutation / upsert / 原生聚合
20
+ */
21
+
22
+ const { setConnections, _now, _ctx, _call, _exec, _substitute, resolvePlaceholders } = require('./exec');
23
+ const { _generateId, _truthy, _newIdPool } = require('./id');
24
+ const { query, queryOne, queryWithCount, queryFederated } = require('./query');
25
+ const { insert, insertMany, update, updateMany, remove, exists, count } = require('./write');
26
+ const { mutation, upsert, aggregate } = require('./mutation');
27
+
28
+ module.exports = {
29
+ setConnections,
30
+ query,
31
+ queryOne,
32
+ queryWithCount,
33
+ queryFederated,
34
+ insert,
35
+ insertMany,
36
+ update,
37
+ updateMany,
38
+ remove,
39
+ exists,
40
+ count,
41
+ mutation,
42
+ upsert,
43
+ aggregate,
44
+ // ── Host 契约件(供跨语言同构契约测试与高级用法;下划线表示内部语义) ──
45
+ _substitute,
46
+ resolvePlaceholders,
47
+ _generateId,
48
+ _truthy,
49
+ _newIdPool,
50
+ // ── 内部工具(包内共享) ──
51
+ _now,
52
+ _ctx,
53
+ _call,
54
+ _exec,
55
+ };
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Mutation / Upsert / 原生聚合 —— 规划步骤序列 → 依序执行 + 父子 _id 占位符回填
5
+ */
6
+
7
+ const { core: _core, get: _getSchema } = require('../schema');
8
+ const { _call, _ctx, _exec, _now, resolvePlaceholders } = require('./exec');
9
+ const { _generateId, _newIdPool } = require('./id');
10
+
11
+ /** mutation 单条:规划步骤序列 → 依序执行 + 父子 _id 占位符回填 */
12
+ async function _mutationOne(schemaName, data, routeOverride = null) {
13
+ const plan = _call(() =>
14
+ _core.planMutation(schemaName, data, _now(), _newIdPool(schemaName, data), _ctx(),
15
+ routeOverride));
16
+
17
+ const resolved = [];
18
+ let rootResult = null;
19
+ for (const [i, step] of plan.steps.entries()) {
20
+ const cmd = resolvePlaceholders(step.command, { steps: resolved });
21
+ const result = await _exec(cmd);
22
+ resolved.push(result ? (result._id ?? null) : null);
23
+ if (i === 0) rootResult = result; // 首步即根写入
24
+ }
25
+
26
+ return rootResult ? _call(() => _core.applyWriteDefaults(schemaName, rootResult)) : null;
27
+ }
28
+
29
+ /**
30
+ * mutation — 智能持久化
31
+ *
32
+ * 自动判断 upsert/insert,支持父子文档关联填充。
33
+ * `routeOverride` 可选:`{ source?, namespace? }` 多租户路由。
34
+ */
35
+ async function mutation(schemaName, data, routeOverride = null) {
36
+ const isArray = Array.isArray(data);
37
+ const items = isArray ? data : [data];
38
+
39
+ if (!items.length) return isArray ? [] : null;
40
+
41
+ const results = [];
42
+ for (const item of items) {
43
+ results.push(await _mutationOne(schemaName, item, routeOverride));
44
+ }
45
+
46
+ return isArray ? results : results[0];
47
+ }
48
+
49
+ /**
50
+ * upsert — 显式条件 upsert
51
+ *
52
+ * 与 mutation 不同,upsert 需要调用方显式提供 match 条件,不处理父子关系。
53
+ */
54
+ async function upsert(schemaName, condition, data, options = null, routeOverride = null) {
55
+ const s = _getSchema(schemaName);
56
+ const plan = _call(() => _core.planUpsert(
57
+ schemaName, condition ?? null, data ?? null, options ?? null, _now(),
58
+ s.idPrefix ? _generateId(s) : '', _ctx(), routeOverride,
59
+ ));
60
+ const result = await _exec(plan.command);
61
+ return result ? _call(() => _core.applyWriteDefaults(schemaName, result)) : null;
62
+ }
63
+
64
+ // ─── 原生聚合 ────────────────────────────────────────────────
65
+
66
+ /** 对指定 schema 执行 MongoDB 原生聚合查询 */
67
+ async function aggregate(schemaName, pipeline, routeOverride = null) {
68
+ const cmd = _call(() => _core.planAggregate(schemaName, pipeline ?? [], routeOverride));
69
+ return _exec(cmd);
70
+ }
71
+
72
+ module.exports = { mutation, upsert, aggregate };