nodejs-store 1.0.0 → 2.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/README.md +115 -4
- package/package.json +11 -3
- package/src/core.js +6 -0
- package/src/crud/exec.js +28 -44
- package/src/crud/id.js +8 -3
- package/src/crud/index.js +5 -5
- package/src/crud/mutation.js +35 -22
- package/src/crud/query.js +10 -11
- package/src/crud/write.js +30 -17
- package/src/datasource.js +113 -7
- package/src/executors/_values.js +55 -0
- package/src/executors/index.js +6 -1
- package/src/executors/mongo.js +102 -0
- package/src/executors/mysql.js +40 -12
- package/src/executors/postgres.js +50 -10
- package/src/executors/sqlite.js +38 -13
- package/src/feedback.js +39 -0
- package/src/index.js +45 -15
- package/src/introspect/_shared.js +28 -0
- package/src/introspect/mysql.js +5 -15
- package/src/introspect/postgres.js +5 -15
- package/src/permission.js +51 -0
- package/src/schema.js +18 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ This is the Node.js port of [`py-store`](https://github.com/coenddt/py-store)
|
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| MongoDB | native aggregation pipeline (`find`/`aggregate`/`$lookup`) |
|
|
12
12
|
| MySQL | parameterized SQL, `information_schema` introspection |
|
|
13
|
-
| SQLite | parameterized SQL, `sqlite_master` + `PRAGMA` 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
14
|
| PostgreSQL | parameterized SQL (`$n`), `RETURNING` support |
|
|
15
15
|
|
|
16
16
|
GQL tree queries compile to a single native query per backend — never hand-write `$lookup` or raw SQL again.
|
|
@@ -105,6 +105,10 @@ await store.query('User($condition:@c0){...}', params, { namespace: 'tenant_42'
|
|
|
105
105
|
await store.insert('Order', data, { source: 'pg_cluster', namespace: 'tenant_7' });
|
|
106
106
|
```
|
|
107
107
|
|
|
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.
|
|
111
|
+
|
|
108
112
|
Legacy single-db usage (`init(db)` + schema without `datasource`/`namespace`) is unchanged:
|
|
109
113
|
commands carry `source: 'default'`, `namespace: null`.
|
|
110
114
|
|
|
@@ -119,7 +123,11 @@ Model($condition:@c0,$sort:@s1,$skip:@sk,$limit:@l1) {
|
|
|
119
123
|
|
|
120
124
|
- Values come from the params object: `{ c0: {...}, s1: {...} }`.
|
|
121
125
|
- Object sub-fields use dot notation; relations are declared in the schema (`type: 'many' | 'one'`) and resolved automatically — **do not hand-write `$lookup`**.
|
|
122
|
-
|
|
126
|
+
|
|
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.
|
|
123
131
|
|
|
124
132
|
## Query & write API
|
|
125
133
|
|
|
@@ -138,7 +146,6 @@ await store.updateMany('Post', { type: t }, { status: 'live' });
|
|
|
138
146
|
const r = await store.remove('Post', { _id: pid }); // archives to <collection>_deleted first
|
|
139
147
|
await store.mutation('Post', { ... }); // smart upsert + recursive relation children
|
|
140
148
|
await store.upsert('Post', { code: 'A1' }, { ... }); // explicit-condition upsert (no relation handling)
|
|
141
|
-
const rows = await store.aggregate('Post', pipeline); // native aggregation
|
|
142
149
|
```
|
|
143
150
|
|
|
144
151
|
Notes:
|
|
@@ -165,6 +172,22 @@ await store.runAsInternal(() => store.remove('Post', { _id: pid }));
|
|
|
165
172
|
- No context set → permission checks disabled (backward compatible).
|
|
166
173
|
- Denied access throws `store.PermissionError` (with `status = 403`).
|
|
167
174
|
|
|
175
|
+
### Fail-secure mode (opt-in)
|
|
176
|
+
|
|
177
|
+
"No context" can mean both *system call* and *caller forgot the context* — by default the
|
|
178
|
+
latter silently passes every check (fail-open, kept for backward compatibility). For
|
|
179
|
+
security-sensitive hosts, enable the context requirement once at startup:
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
store.setRequireContext(true);
|
|
183
|
+
// now every query/write without a context throws `ERR_NO_CONTEXT:...`
|
|
184
|
+
// internal jobs must be explicit:
|
|
185
|
+
await store.runAsInternal(() => store.remove('Post', { _id: pid }));
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`runAsInternal` marks the call as `{ internal: true }`, which is semantically distinct from
|
|
189
|
+
a missing context and always passes. `setRequireContext(false)` restores the default.
|
|
190
|
+
|
|
168
191
|
## Schema reference
|
|
169
192
|
|
|
170
193
|
```js
|
|
@@ -183,7 +206,7 @@ await store.runAsInternal(() => store.remove('Post', { _id: pid }));
|
|
|
183
206
|
},
|
|
184
207
|
computes: {
|
|
185
208
|
total: { type: 'float', depends: ['amount'], fn: (d) => d.amount * 1.1 },
|
|
186
|
-
itemCount: { type: 'int',
|
|
209
|
+
itemCount: { type: 'int', agg: { $count: 'items' } },
|
|
187
210
|
},
|
|
188
211
|
indexes: [
|
|
189
212
|
{ keys: { status: 1 } },
|
|
@@ -196,6 +219,94 @@ await store.runAsInternal(() => store.remove('Post', { _id: pid }));
|
|
|
196
219
|
|
|
197
220
|
Types: `string | int | long | float | double | boolean | array | object | date | any`.
|
|
198
221
|
|
|
222
|
+
## Advanced API
|
|
223
|
+
|
|
224
|
+
Everything below is reachable from the exported `store` singleton or the modules it
|
|
225
|
+
re-exports. Options prefixed with `?` are optional.
|
|
226
|
+
|
|
227
|
+
### `store.buildPipeline(gql, params?)`
|
|
228
|
+
|
|
229
|
+
Low-level parse — compiles GQL to the command plan **without executing it**, returning
|
|
230
|
+
`{ tokens, ast, pipeline, projection }`. Useful for debugging query shape, asserting
|
|
231
|
+
pushdown behaviour, or building custom tooling. Permissions / computes are **not** applied here.
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
const plan = store.buildPipeline('Post($condition:@c0){ title }', { c0: { status: 'draft' } });
|
|
235
|
+
console.log(plan.pipeline);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### `store.syncSchema(opts)`
|
|
239
|
+
|
|
240
|
+
Pull a SQL backend's physical structure into the registry
|
|
241
|
+
(`introspect → schemaFromRows → mergeSchema(overlay) → register`). It only **reads** the
|
|
242
|
+
structure — it never writes DDL back to the database.
|
|
243
|
+
|
|
244
|
+
| Option | Type | Meaning |
|
|
245
|
+
| --- | --- | --- |
|
|
246
|
+
| `backend` | `'mysql' \| 'postgres' \| 'sqlite'` | required |
|
|
247
|
+
| `driver` | object | required; prefer a read-only account |
|
|
248
|
+
| `introspectOptions` | object | passed through to introspection (e.g. PG `schema`) |
|
|
249
|
+
| `overlay` | `Array` | local schemaJSON merged on top (permissions / computes / overrides) |
|
|
250
|
+
| `datasource` | string | bind every merged def to this source |
|
|
251
|
+
| `namespace` | string | bind every merged def to this namespace |
|
|
252
|
+
| `registerDefs` | boolean (default `true`) | `false` = return defs without registering |
|
|
253
|
+
|
|
254
|
+
Returns the merged `schemaJSON[]`.
|
|
255
|
+
|
|
256
|
+
```js
|
|
257
|
+
const defs = await store.syncSchema({
|
|
258
|
+
backend: 'postgres', driver: pgPool, overlay: [Post], datasource: 'pg_a',
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### `store.setFeedbackSink(fn)`
|
|
263
|
+
|
|
264
|
+
Take over the unified feedback channel used for fallback / degradation / interception
|
|
265
|
+
events. The sink receives one event object; pass `null` (or a non-function) to fall back to
|
|
266
|
+
the default stderr printer.
|
|
267
|
+
|
|
268
|
+
```js
|
|
269
|
+
store.setFeedbackSink((e) => logger.warn({ code: e.code }, e.hint));
|
|
270
|
+
// event shape: { type, code, layer, message, hint, ... }
|
|
271
|
+
// type federation_degraded | sql_pushdown_unsupported | ...
|
|
272
|
+
// code crossSourceSort | pushdownUnsupported | ...
|
|
273
|
+
// layer federation | dialect | ...
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Low-level modules
|
|
277
|
+
|
|
278
|
+
The package re-exports its building blocks for advanced hosts:
|
|
279
|
+
|
|
280
|
+
```js
|
|
281
|
+
const {
|
|
282
|
+
init, store, Store,
|
|
283
|
+
PermissionError, // thrown on denied access (status = 403)
|
|
284
|
+
PushdownUnsupportedError, // thrown when a command cannot be safely pushed down
|
|
285
|
+
datasource, schema, permission, crud, executors, feedback, introspect,
|
|
286
|
+
syncSchema, // same function as store.syncSchema
|
|
287
|
+
} = require('nodejs-store');
|
|
288
|
+
|
|
289
|
+
// introspect.run(backend, driver, options) → normalized structure rows
|
|
290
|
+
const rows = await introspect.run('mysql', pool, {});
|
|
291
|
+
|
|
292
|
+
// executors.createConnection(kind, driver, options) → SQL datasource descriptor { kind, exec }
|
|
293
|
+
await init({ default: db, pg_a: executors.createConnection('postgres', pgPool) });
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
- `schema` / `permission` / `feedback` / `datasource` expose the same functions the `store`
|
|
297
|
+
singleton delegates to (e.g. `datasource.setConnections`, `datasource.hasConnection`,
|
|
298
|
+
`datasource.isSql`, `datasource.runInTransaction`).
|
|
299
|
+
- **Multi-tenant route override** — pass `{ source, namespace }` as the last argument of any
|
|
300
|
+
query/write, see [Multi-datasource connections](#multi-datasource-connections).
|
|
301
|
+
|
|
302
|
+
## Transaction boundary
|
|
303
|
+
|
|
304
|
+
- **Single SQL source**: `mutation` parent-child step sequences and `remove` (archive + delete) run inside one driver transaction on one checked-out connection — any step failure rolls back the whole sequence.
|
|
305
|
+
- **Each SQL write command** is itself atomic: multi-statement plans (e.g. MySQL write + readback) are transaction-wrapped in the executor.
|
|
306
|
+
- **Mongo sources**: single-document writes are atomic; multi-step `mutation` and `remove` execute sequentially and are **not** atomic across steps (Mongo transactions require a replica set). If your consistency requirement spans steps on Mongo, either use an SQL source for those models or add application-level compensation.
|
|
307
|
+
- **Archive idempotency**: `remove` archives with upsert-by-`_id` semantics, so a retry after partial failure no longer fails on duplicate `_id`.
|
|
308
|
+
- **Cross-source steps** (parent and child bound to different datasources) cannot be atomic — they run sequentially by design.
|
|
309
|
+
|
|
199
310
|
## License
|
|
200
311
|
|
|
201
312
|
[MIT](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodejs-store",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
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
5
|
"main": "src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"LICENSE"
|
|
10
10
|
],
|
|
11
11
|
"scripts": {
|
|
12
|
-
"test": "node scripts/test.js"
|
|
12
|
+
"test": "node scripts/test.js",
|
|
13
|
+
"test:coverage": "c8 --all --include src/** -x **/rust-store/** --reporter=text --reporter=html --check-coverage --statements 90 --lines 90 --functions 85 --branches 75 node scripts/test.js",
|
|
14
|
+
"lint": "eslint ."
|
|
13
15
|
},
|
|
14
16
|
"keywords": [
|
|
15
17
|
"mongodb",
|
|
@@ -41,6 +43,12 @@
|
|
|
41
43
|
"mongodb": "^6.21.0",
|
|
42
44
|
"mysql2": "^3.24.4",
|
|
43
45
|
"pg": "^8.23.0",
|
|
44
|
-
"rust-store-node": "^
|
|
46
|
+
"rust-store-node": "^2.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@eslint/js": "^9.39.2",
|
|
50
|
+
"c8": "^10.1.3",
|
|
51
|
+
"eslint": "^9.39.2",
|
|
52
|
+
"globals": "^15.15.0"
|
|
45
53
|
}
|
|
46
54
|
}
|
package/src/core.js
CHANGED
|
@@ -34,6 +34,12 @@ function _requireDevFallback() {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function _load() {
|
|
37
|
+
// LOCAL_CORE=1(且非 production)优先加载本地调试产物 —— 使「从相邻 rust-store
|
|
38
|
+
// 仓库加载」的语义与文档一致;未设置或加载失败再走 npm 依赖。
|
|
39
|
+
if (process.env.LOCAL_CORE === '1' && process.env.NODE_ENV !== 'production') {
|
|
40
|
+
const local = _requireDevFallback();
|
|
41
|
+
if (local && !local.__error) return local;
|
|
42
|
+
}
|
|
37
43
|
try {
|
|
38
44
|
return require('rust-store-node');
|
|
39
45
|
} catch (e) {
|
package/src/crud/exec.js
CHANGED
|
@@ -14,76 +14,59 @@
|
|
|
14
14
|
|
|
15
15
|
const { PermissionError, getContext } = require('../permission');
|
|
16
16
|
const datasource = require('../datasource');
|
|
17
|
+
const { execMongo } = require('../executors/mongo');
|
|
18
|
+
const { get: _getSchema } = require('../schema');
|
|
17
19
|
|
|
18
20
|
const _PHASE1_IDS = /^\{\{phase1\.ids\}\}$/;
|
|
19
21
|
const _STEP_PH = /^\{\{step\.(\d+)\._id\}\}$/;
|
|
20
22
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* 权限类错误识别:core 权限错误统一携带 `ERR_PERMISSION:` 稳定前缀(见 core
|
|
25
|
+
* `command/mod.rs::ERR_PERM_PREFIX`),按**前缀**映射而非具体文案 —— core 文案
|
|
26
|
+
* 可自由调整,映射不随文案漂移而静默失效。构造 PermissionError 时剥离前缀。
|
|
27
|
+
*/
|
|
28
|
+
const _PERM_PREFIX = 'ERR_PERMISSION:';
|
|
23
29
|
|
|
24
30
|
/** 设置数据源连接映射(对 `../datasource` 的路由入口做包内透出) */
|
|
25
31
|
const setConnections = datasource.setConnections;
|
|
26
32
|
|
|
27
|
-
/**
|
|
28
|
-
function
|
|
29
|
-
|
|
33
|
+
/** 单库简写:等价于 `setConnections({ default: db })`(对齐 py_store.crud.exec.set_db) */
|
|
34
|
+
function setDb(db) {
|
|
35
|
+
datasource.setConnections({ [datasource.DEFAULT_SOURCE]: db });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 按 schema 的 timestamps 单位产出当前时间戳('s' → 秒,其余/未启用 → 毫秒) */
|
|
39
|
+
function _nowFor(schemaName) {
|
|
40
|
+
const unit = _getSchema(schemaName).timestampUnit;
|
|
41
|
+
return unit === 's' ? Math.floor(Date.now() / 1000) : Date.now();
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
function _ctx() {
|
|
33
45
|
return getContext() ?? null;
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
/**
|
|
48
|
+
/** 绑定层调用包装:权限类错误(`ERR_PERMISSION:` 前缀)映射为 PermissionError */
|
|
37
49
|
function _call(fn) {
|
|
38
50
|
try {
|
|
39
51
|
return fn();
|
|
40
52
|
} catch (e) {
|
|
41
|
-
|
|
53
|
+
const msg = e && e.message;
|
|
54
|
+
if (typeof msg === 'string' && msg.startsWith(_PERM_PREFIX)) {
|
|
55
|
+
throw new PermissionError(msg.slice(_PERM_PREFIX.length));
|
|
56
|
+
}
|
|
42
57
|
throw e;
|
|
43
58
|
}
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
// ─── 命令执行(唯一 IO 边界) ────────────────────────────────
|
|
47
62
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
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) */
|
|
63
|
+
/** 在指定数据源上执行命令(Mongo 走原生驱动,SQL 走 translate → exec;
|
|
64
|
+
* 事务作用域内经 datasource.connectionFor 落到事务专用连接) */
|
|
82
65
|
async function _execOn(source, cmd) {
|
|
83
|
-
const connection = datasource.
|
|
66
|
+
const connection = datasource.connectionFor(source);
|
|
84
67
|
const db = datasource.mongoDb(connection, source, cmd.namespace ?? null);
|
|
85
68
|
if (db) {
|
|
86
|
-
return
|
|
69
|
+
return execMongo(db, cmd);
|
|
87
70
|
}
|
|
88
71
|
return datasource.execSql(source, connection, cmd);
|
|
89
72
|
}
|
|
@@ -129,7 +112,8 @@ function resolvePlaceholders(command, { ids = null, steps = [] } = {}) {
|
|
|
129
112
|
|
|
130
113
|
module.exports = {
|
|
131
114
|
setConnections,
|
|
132
|
-
|
|
115
|
+
setDb,
|
|
116
|
+
_nowFor,
|
|
133
117
|
_ctx,
|
|
134
118
|
_call,
|
|
135
119
|
_exec,
|
package/src/crud/id.js
CHANGED
|
@@ -4,18 +4,23 @@
|
|
|
4
4
|
* ID 供给(Host 随机源) —— 与 core `needs_new_id` 语义对齐
|
|
5
5
|
*
|
|
6
6
|
* core 无随机源:需要新 _id 时由 Host 按序供给,本模块负责生成与遍历。
|
|
7
|
+
* 随机段使用 crypto 强随机源 8 位 base36(约 41 bit 熵):Math.random 仅 4 位
|
|
8
|
+
* (36^4 ≈ 168 万组合),insertMany 同毫秒批量生成时碰撞概率不可忽略(CWE-338)。
|
|
7
9
|
*/
|
|
8
10
|
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
|
|
9
13
|
const { get: _getSchema } = require('../schema');
|
|
10
14
|
|
|
11
15
|
const _ID_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
12
16
|
|
|
13
|
-
/** 按 schema.idPrefix 生成唯一 ID(时间戳36进制 + 随机
|
|
17
|
+
/** 按 schema.idPrefix 生成唯一 ID(时间戳36进制 + crypto 随机8位) */
|
|
14
18
|
function _generateId(schema) {
|
|
15
19
|
const ts = Date.now().toString(36).toUpperCase();
|
|
16
20
|
let rnd = '';
|
|
17
|
-
for (let i = 0; i <
|
|
18
|
-
|
|
21
|
+
for (let i = 0; i < 8; i++) {
|
|
22
|
+
// crypto.randomInt 内部拒绝采样,无取模偏差
|
|
23
|
+
rnd += _ID_CHARS[crypto.randomInt(_ID_CHARS.length)];
|
|
19
24
|
}
|
|
20
25
|
return schema.idPrefix + ts + rnd.toUpperCase();
|
|
21
26
|
}
|
package/src/crud/index.js
CHANGED
|
@@ -16,17 +16,18 @@
|
|
|
16
16
|
* - [`id`]:ID 生成与 mutation ID 池遍历
|
|
17
17
|
* - [`query`]:读路径
|
|
18
18
|
* - [`write`]:写路径
|
|
19
|
-
* - [`mutation`]:mutation / upsert
|
|
19
|
+
* - [`mutation`]:mutation / upsert
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
const { setConnections,
|
|
22
|
+
const { setConnections, setDb, _nowFor, _ctx, _call, _exec, _substitute, resolvePlaceholders } = require('./exec');
|
|
23
23
|
const { _generateId, _truthy, _newIdPool } = require('./id');
|
|
24
24
|
const { query, queryOne, queryWithCount, queryFederated } = require('./query');
|
|
25
25
|
const { insert, insertMany, update, updateMany, remove, exists, count } = require('./write');
|
|
26
|
-
const { mutation, upsert
|
|
26
|
+
const { mutation, upsert } = require('./mutation');
|
|
27
27
|
|
|
28
28
|
module.exports = {
|
|
29
29
|
setConnections,
|
|
30
|
+
setDb,
|
|
30
31
|
query,
|
|
31
32
|
queryOne,
|
|
32
33
|
queryWithCount,
|
|
@@ -40,7 +41,6 @@ module.exports = {
|
|
|
40
41
|
count,
|
|
41
42
|
mutation,
|
|
42
43
|
upsert,
|
|
43
|
-
aggregate,
|
|
44
44
|
// ── Host 契约件(供跨语言同构契约测试与高级用法;下划线表示内部语义) ──
|
|
45
45
|
_substitute,
|
|
46
46
|
resolvePlaceholders,
|
|
@@ -48,7 +48,7 @@ module.exports = {
|
|
|
48
48
|
_truthy,
|
|
49
49
|
_newIdPool,
|
|
50
50
|
// ── 内部工具(包内共享) ──
|
|
51
|
-
|
|
51
|
+
_nowFor,
|
|
52
52
|
_ctx,
|
|
53
53
|
_call,
|
|
54
54
|
_exec,
|
package/src/crud/mutation.js
CHANGED
|
@@ -5,25 +5,42 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { core: _core, get: _getSchema } = require('../schema');
|
|
8
|
-
const
|
|
8
|
+
const datasource = require('../datasource');
|
|
9
|
+
const { emit: _emitFeedback } = require('../feedback');
|
|
10
|
+
const { _call, _ctx, _exec, _nowFor, resolvePlaceholders } = require('./exec');
|
|
9
11
|
const { _generateId, _newIdPool } = require('./id');
|
|
10
12
|
|
|
11
13
|
/** mutation 单条:规划步骤序列 → 依序执行 + 父子 _id 占位符回填 */
|
|
12
|
-
async function _mutationOne(schemaName, data, routeOverride = null) {
|
|
14
|
+
async function _mutationOne(schemaName, data, now, routeOverride = null) {
|
|
13
15
|
const plan = _call(() =>
|
|
14
|
-
_core.planMutation(schemaName, data,
|
|
16
|
+
_core.planMutation(schemaName, data, now, _newIdPool(schemaName, data), _ctx(),
|
|
15
17
|
routeOverride));
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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; // 首步即根写入
|
|
19
|
+
// §11.4 静默点收口:规划期降级(如关系不可读被跳过)走统一反馈通道,禁止静默失守
|
|
20
|
+
for (const d of plan.degraded || []) {
|
|
21
|
+
_emitFeedback({ ...(d || {}), type: 'mutation_degraded' });
|
|
24
22
|
}
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
const runSteps = async () => {
|
|
25
|
+
const resolved = [];
|
|
26
|
+
let rootResult = null;
|
|
27
|
+
for (const [i, step] of plan.steps.entries()) {
|
|
28
|
+
const cmd = resolvePlaceholders(step.command, { steps: resolved });
|
|
29
|
+
const result = await _exec(cmd);
|
|
30
|
+
resolved.push(result ? (result._id ?? null) : null);
|
|
31
|
+
if (i === 0) rootResult = result; // 首步即根写入
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return rootResult ? _call(() => _core.applyWriteDefaults(schemaName, rootResult)) : null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// 单一 SQL 源 → 步骤序列整体事务化(同连接同事务,任一步失败整体回滚);
|
|
38
|
+
// Mongo 源 / 跨源步骤按原样顺序执行(非原子边界见 README「事务边界」)
|
|
39
|
+
const sources = [...new Set(plan.steps.map((s) => s.command.source || datasource.DEFAULT_SOURCE))];
|
|
40
|
+
if (sources.length === 1 && datasource.isSql(sources[0])) {
|
|
41
|
+
return datasource.runInTransaction(sources[0], runSteps);
|
|
42
|
+
}
|
|
43
|
+
return runSteps();
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
/**
|
|
@@ -38,9 +55,13 @@ async function mutation(schemaName, data, routeOverride = null) {
|
|
|
38
55
|
|
|
39
56
|
if (!items.length) return isArray ? [] : null;
|
|
40
57
|
|
|
58
|
+
// §11.3 确定性输入:一次 mutation 调用共用一个 now
|
|
59
|
+
// (数组内多条 + 父子步骤 + 默认值 / 计算列全部同值)
|
|
60
|
+
const now = _nowFor(schemaName);
|
|
61
|
+
|
|
41
62
|
const results = [];
|
|
42
63
|
for (const item of items) {
|
|
43
|
-
results.push(await _mutationOne(schemaName, item, routeOverride));
|
|
64
|
+
results.push(await _mutationOne(schemaName, item, now, routeOverride));
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
return isArray ? results : results[0];
|
|
@@ -54,19 +75,11 @@ async function mutation(schemaName, data, routeOverride = null) {
|
|
|
54
75
|
async function upsert(schemaName, condition, data, options = null, routeOverride = null) {
|
|
55
76
|
const s = _getSchema(schemaName);
|
|
56
77
|
const plan = _call(() => _core.planUpsert(
|
|
57
|
-
schemaName, condition ?? null, data ?? null, options ?? null,
|
|
78
|
+
schemaName, condition ?? null, data ?? null, options ?? null, _nowFor(schemaName),
|
|
58
79
|
s.idPrefix ? _generateId(s) : '', _ctx(), routeOverride,
|
|
59
80
|
));
|
|
60
81
|
const result = await _exec(plan.command);
|
|
61
82
|
return result ? _call(() => _core.applyWriteDefaults(schemaName, result)) : null;
|
|
62
83
|
}
|
|
63
84
|
|
|
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 };
|
|
85
|
+
module.exports = { mutation, upsert };
|
package/src/crud/query.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const { core: _core, getAsyncFn } = require('../schema');
|
|
9
|
+
const { emit: _emitFeedback } = require('../feedback');
|
|
9
10
|
const { _call, _ctx, _exec, _execOn, resolvePlaceholders } = require('./exec');
|
|
10
11
|
|
|
11
12
|
/** 执行读命令序列:find 快路径 / 两阶段(取 ID → 关联 → 还原排序)/ 标准聚合 */
|
|
@@ -37,8 +38,7 @@ async function _finalize(plan, items) {
|
|
|
37
38
|
* GQL 查询(返回数组)
|
|
38
39
|
*
|
|
39
40
|
* 支持的 params 键(通过 GQL 的 @key 引用):
|
|
40
|
-
* $condition / $sort / $skip / $limit
|
|
41
|
-
* 使用 $pipeline 时,框架不追加 compute 层、不补默认值、不裁剪,完全由用户控制。
|
|
41
|
+
* $condition / $sort / $skip / $limit
|
|
42
42
|
*
|
|
43
43
|
* `routeOverride`(多租户路由,可选):`{ source?, namespace? }`,覆盖命令定位,
|
|
44
44
|
* 权限/计算列仍按结构 schema 判定(见 multi-datasource-routing-plan.md §6)。
|
|
@@ -48,9 +48,10 @@ async function query(gql, params = null, routeOverride = null) {
|
|
|
48
48
|
return _finalize(plan, await _runQueryPlan(plan));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** GQL
|
|
51
|
+
/** GQL 查询(返回单条)—— 走 core `planQueryOne`:未显式 `$limit` 时下推 `$limit(1)` */
|
|
52
52
|
async function queryOne(gql, params = null, routeOverride = null) {
|
|
53
|
-
const
|
|
53
|
+
const plan = _call(() => _core.planQueryOne(gql, params ?? {}, _ctx(), routeOverride));
|
|
54
|
+
const items = await _finalize(plan, await _runQueryPlan(plan));
|
|
54
55
|
return items.length ? items[0] : null;
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -75,8 +76,8 @@ async function _runFederatedUnit(unit) {
|
|
|
75
76
|
/**
|
|
76
77
|
* 跨库联邦查询(返回嵌套文档数组)
|
|
77
78
|
*
|
|
78
|
-
* Host 四步:core `planFederated` 拆源 →
|
|
79
|
-
* 内存 hash join → 统一后处理(`_finalize`,与单库同一路径)。
|
|
79
|
+
* Host 四步:core `planFederated` 拆源 → 并行逐源执行(任一源失败整体失败)
|
|
80
|
+
* → core `mergeFederated` 内存 hash join → 统一后处理(`_finalize`,与单库同一路径)。
|
|
80
81
|
*
|
|
81
82
|
* `postprocess` 取自根单元快照(含全部关系),因此结果形状与单库 `query` 完全一致。
|
|
82
83
|
* 每源取数上限 `MAX_FEDERATION_ROWS` 由 core 强制(超限即报错,拒绝静默全表拉取);
|
|
@@ -86,13 +87,11 @@ async function queryFederated(gql, params = null) {
|
|
|
86
87
|
const plan = _call(() => _core.planFederated(gql, params ?? {}, _ctx()));
|
|
87
88
|
|
|
88
89
|
for (const d of plan.degraded || []) {
|
|
89
|
-
|
|
90
|
+
// 降级事件走统一反馈通道(无 sink 时打 stderr,允许拦截,禁止静默失守)
|
|
91
|
+
_emitFeedback({ ...(d || {}), type: 'federation_degraded' });
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
const results = [];
|
|
93
|
-
for (const unit of plan.sources || []) {
|
|
94
|
-
results.push(await _runFederatedUnit(unit));
|
|
95
|
-
}
|
|
94
|
+
const results = await Promise.all((plan.sources || []).map(_runFederatedUnit));
|
|
96
95
|
|
|
97
96
|
const merged = _call(() => _core.mergeFederated(plan, results));
|
|
98
97
|
return _finalize(plan, merged);
|
package/src/crud/write.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { core: _core, get: _getSchema } = require('../schema');
|
|
8
|
-
const
|
|
8
|
+
const datasource = require('../datasource');
|
|
9
|
+
const { _call, _ctx, _exec, _nowFor } = require('./exec');
|
|
9
10
|
const { _generateId } = require('./id');
|
|
10
11
|
|
|
11
12
|
/** creator 写权限探针:先规划,若 needsProbe 则执行探针命令后重入 */
|
|
@@ -22,7 +23,7 @@ async function _planWithProbe(planFn) {
|
|
|
22
23
|
async function insert(schemaName, data, routeOverride = null) {
|
|
23
24
|
const s = _getSchema(schemaName);
|
|
24
25
|
const plan = _call(() =>
|
|
25
|
-
_core.planInsert(schemaName, data ?? null,
|
|
26
|
+
_core.planInsert(schemaName, data ?? null, _nowFor(schemaName), s.idPrefix ? _generateId(s) : '', _ctx(),
|
|
26
27
|
routeOverride));
|
|
27
28
|
await _exec(plan.command);
|
|
28
29
|
return plan.returns;
|
|
@@ -36,7 +37,7 @@ async function insertMany(schemaName, docs, routeOverride = null) {
|
|
|
36
37
|
const plan = _call(() => _core.planInsertMany(
|
|
37
38
|
schemaName,
|
|
38
39
|
docs,
|
|
39
|
-
|
|
40
|
+
_nowFor(schemaName),
|
|
40
41
|
// core 按需消费(仅无 _id 的文档取用),多备无害
|
|
41
42
|
docs.map(() => (s.idPrefix ? _generateId(s) : '')),
|
|
42
43
|
_ctx(),
|
|
@@ -54,7 +55,7 @@ async function insertMany(schemaName, docs, routeOverride = null) {
|
|
|
54
55
|
*/
|
|
55
56
|
async function update(schemaName, condition, data, options = null, routeOverride = null) {
|
|
56
57
|
const out = await _planWithProbe((found, doc) => _call(() =>
|
|
57
|
-
_core.planUpdate(schemaName, condition ?? null, data ?? null, options ?? null,
|
|
58
|
+
_core.planUpdate(schemaName, condition ?? null, data ?? null, options ?? null, _nowFor(schemaName), _ctx(),
|
|
58
59
|
found, doc, routeOverride)));
|
|
59
60
|
const result = await _exec(out.command);
|
|
60
61
|
return result ? _call(() => _core.applyWriteDefaults(schemaName, result)) : null;
|
|
@@ -63,28 +64,40 @@ async function update(schemaName, condition, data, options = null, routeOverride
|
|
|
63
64
|
/** 批量更新(支持原生操作符) */
|
|
64
65
|
async function updateMany(schemaName, condition, data, routeOverride = null) {
|
|
65
66
|
const out = _call(() =>
|
|
66
|
-
_core.planUpdateMany(schemaName, condition ?? null, data ?? null,
|
|
67
|
+
_core.planUpdateMany(schemaName, condition ?? null, data ?? null, _nowFor(schemaName), _ctx(), routeOverride));
|
|
67
68
|
const result = await _exec(out.command);
|
|
68
69
|
return { modifiedCount: result.modifiedCount };
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
/** 删除 —— 原表数据先归档到对应 `_deleted` 附表(附 deletedAt
|
|
72
|
+
/** 删除 —— 原表数据先归档到对应 `_deleted` 附表(附 deletedAt),再物理删除原表数据。
|
|
73
|
+
* 归档命令带 `upsertById`(幂等),重试不再因 _id 冲突整批失败;单一 SQL 源时
|
|
74
|
+
* 归档+删除整体事务化(Mongo / 跨源按顺序执行,非原子边界见 README「事务边界」) */
|
|
72
75
|
async function remove(schemaName, condition, routeOverride = null) {
|
|
73
76
|
const out = await _planWithProbe((found, doc) => _call(() =>
|
|
74
77
|
_core.planRemove(schemaName, condition ?? null, _ctx(), found, doc, routeOverride)));
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
const doRemove = async () => {
|
|
80
|
+
let archivedCount = 0;
|
|
81
|
+
if (out.findCommand) {
|
|
82
|
+
const docs = await _exec(out.findCommand);
|
|
83
|
+
if (docs.length) {
|
|
84
|
+
const arch = _call(() => _core.planArchiveDocs(schemaName, docs, _nowFor(schemaName), routeOverride));
|
|
85
|
+
await _exec(arch.command);
|
|
86
|
+
archivedCount = docs.length;
|
|
87
|
+
}
|
|
83
88
|
}
|
|
84
|
-
}
|
|
85
89
|
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
const result = await _exec(out.deleteCommand);
|
|
91
|
+
return { deletedCount: result.deletedCount, archivedCount };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const sources = new Set([out.deleteCommand.source || datasource.DEFAULT_SOURCE]);
|
|
95
|
+
if (out.findCommand) sources.add(out.findCommand.source || datasource.DEFAULT_SOURCE);
|
|
96
|
+
const arr = [...sources];
|
|
97
|
+
if (arr.length === 1 && datasource.isSql(arr[0])) {
|
|
98
|
+
return datasource.runInTransaction(arr[0], doRemove);
|
|
99
|
+
}
|
|
100
|
+
return doRemove();
|
|
88
101
|
}
|
|
89
102
|
|
|
90
103
|
/** 判断是否存在 */
|
|
@@ -96,7 +109,7 @@ async function exists(schemaName, condition, routeOverride = null) {
|
|
|
96
109
|
|
|
97
110
|
/** 统计符合条件的文档数量 */
|
|
98
111
|
async function count(schemaName, filter = null, routeOverride = null) {
|
|
99
|
-
const cmd = _call(() => _core.planCount(schemaName, filter ?? null, routeOverride));
|
|
112
|
+
const cmd = _call(() => _core.planCount(schemaName, filter ?? null, _ctx(), routeOverride));
|
|
100
113
|
return _exec(cmd);
|
|
101
114
|
}
|
|
102
115
|
|