jsql-neo 6.5.1-beta.2 → 6.5.3
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 +22 -11
- package/bin/jsql +52 -7
- package/index.js +1 -1
- package/lib/mongo_server.js +121 -58
- package/lib/multiserver.js +12 -4
- package/lib/star-nag.js +101 -22
- package/native/jsql-neo-native.node +0 -0
- package/package.json +5 -3
- package/test/mongo-group.test.js +5 -16
- package/test/multiprotocol.test.js +155 -0
- package/test/star-nag.test.js +160 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> **One engine to rule them all** — a Rust-powered embedded database that speaks your language:
|
|
4
4
|
> MySQL, PostgreSQL, MongoDB, Redis, SQL, TypeScript, **and the browser**.
|
|
5
5
|
|
|
6
|
-
> **v6.5.
|
|
6
|
+
> **v6.5.3** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
|
|
7
7
|
|
|
8
8
|

|
|
9
9
|

|
|
@@ -38,16 +38,17 @@ npm install jsql-neo
|
|
|
38
38
|
|
|
39
39
|
**JSQL-NEO** is a Rust-powered embedded database with **zero runtime dependencies**. It runs in Node.js, CLI, Web UI, and the browser (WASM).
|
|
40
40
|
|
|
41
|
-
- **
|
|
41
|
+
- **Four wire protocols on one port** — `jsql serve` accepts MySQL, PostgreSQL, MongoDB and Redis clients; `mysql2`, `pg`, `mongodb` and `ioredis` connect directly
|
|
42
42
|
- **Zero dependencies** — no native libs to compile, no system deps to install
|
|
43
43
|
- **Three engines, same API** — `native` (Rust N-API), `wasm` (same Rust core), `js` (pure JS)
|
|
44
44
|
- **~2× faster than better-sqlite3** on indexed point lookups
|
|
45
45
|
|
|
46
|
-
> **
|
|
47
|
-
>
|
|
48
|
-
>
|
|
49
|
-
>
|
|
50
|
-
>
|
|
46
|
+
> **Wire protocols** — MySQL, PostgreSQL, MongoDB and Redis share a single listener
|
|
47
|
+
> (`jsql serve`, default port 3306). The server sniffs the first packet of each
|
|
48
|
+
> connection and hands it to the matching protocol handler. MySQL / PostgreSQL /
|
|
49
|
+
> MongoDB read the same `Database` engine (same tables), Redis keeps its own key
|
|
50
|
+
> namespace. Verified with `mysql2`, `pg`, `mongodb` (driver 7.x) and `ioredis`;
|
|
51
|
+
> pass `--mysql-only` to serve the MySQL protocol alone.
|
|
51
52
|
|
|
52
53
|
---
|
|
53
54
|
|
|
@@ -100,13 +101,23 @@ db.stop();
|
|
|
100
101
|
jsql serve --port 3306 --data-dir ./data
|
|
101
102
|
```
|
|
102
103
|
|
|
104
|
+
All four protocols share the same port:
|
|
105
|
+
|
|
103
106
|
```bash
|
|
104
|
-
# MySQL clients (mysql CLI, mysql2, DBeaver, DataGrip …)
|
|
105
|
-
mysql -h 127.0.0.1 -P 3306
|
|
107
|
+
# MySQL clients (mysql CLI, mysql2, DBeaver, DataGrip …)
|
|
108
|
+
mysql -h 127.0.0.1 -P 3306 -u root
|
|
109
|
+
|
|
110
|
+
# PostgreSQL clients (psql, pg, pgAdmin …)
|
|
111
|
+
psql "host=127.0.0.1 port=3306 user=postgres dbname=app"
|
|
112
|
+
|
|
113
|
+
# MongoDB clients (mongosh, mongodb driver, Compass …)
|
|
114
|
+
mongosh "mongodb://127.0.0.1:3306/app?directConnection=true"
|
|
115
|
+
|
|
116
|
+
# Redis clients (redis-cli, ioredis …)
|
|
117
|
+
redis-cli -h 127.0.0.1 -p 3306 PING
|
|
106
118
|
```
|
|
107
119
|
|
|
108
|
-
|
|
109
|
-
available as libraries — see the note near the top of this file.
|
|
120
|
+
Pass `--mysql-only` to serve the MySQL wire protocol alone.
|
|
110
121
|
|
|
111
122
|
### Browser / WASM
|
|
112
123
|
|
package/bin/jsql
CHANGED
|
@@ -8,7 +8,9 @@ const migrate = require('../lib/migrate');
|
|
|
8
8
|
const { nag, setStarred, readConfig } = require('../lib/star-nag');
|
|
9
9
|
|
|
10
10
|
// star 提示:任何异常都不该挡住命令本身
|
|
11
|
-
|
|
11
|
+
// version / --version 只回版本号、star 本身就是干这件事的 —— 这些不提示,其余全部提示
|
|
12
|
+
const NO_NAG_ARGS = new Set(['version', '--version', '-v', 'star']);
|
|
13
|
+
if (!process.env.JSQL_NO_STAR_NAG && !NO_NAG_ARGS.has(process.argv[2])) {
|
|
12
14
|
try { nag(); } catch (e) { /* 提示失败与命令无关 */ }
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -154,14 +156,14 @@ const cli = yaggs({ pkg: require('../package.json') })
|
|
|
154
156
|
}
|
|
155
157
|
} catch (e) { fail(e); }
|
|
156
158
|
})
|
|
157
|
-
.command('serve', 'Run the
|
|
159
|
+
.command('serve', 'Run the multi-protocol server (MySQL / PostgreSQL / MongoDB / Redis on one port)', (sub) => {
|
|
158
160
|
sub.option('port', { alias: ['p'], type: 'number', description: 'Listen port (default 3306)' });
|
|
159
161
|
sub.option('host', { type: 'string', description: 'Listen host (default 127.0.0.1)' });
|
|
160
162
|
sub.option('data-dir', { type: 'string', description: 'Directory to store databases' });
|
|
161
163
|
sub.option('no-auth', { type: 'boolean', description: 'Allow connections without authentication' });
|
|
162
164
|
sub.option('log', { type: 'boolean', description: 'Log queries to stdout' });
|
|
165
|
+
sub.option('mysql-only', { type: 'boolean', description: 'Serve the MySQL wire protocol only' });
|
|
163
166
|
}, async (argv) => {
|
|
164
|
-
const { createMysqlServer } = require('../lib/mysql_server');
|
|
165
167
|
const options = {
|
|
166
168
|
port: argv.port != null ? argv.port : 3306,
|
|
167
169
|
host: argv.host || '127.0.0.1',
|
|
@@ -169,9 +171,27 @@ const cli = yaggs({ pkg: require('../package.json') })
|
|
|
169
171
|
noAuth: argv['no-auth'] === true,
|
|
170
172
|
};
|
|
171
173
|
if (argv.log) options.onQuery = (q) => console.log('[sql]', q);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
options.onError = (err) => {
|
|
175
|
+
console.error(`serve failed: ${err.message}`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
};
|
|
178
|
+
const mysqlOnly = argv['mysql-only'] === true;
|
|
179
|
+
if (mysqlOnly) {
|
|
180
|
+
const { createMysqlServer } = require('../lib/mysql_server');
|
|
181
|
+
const srv = createMysqlServer(options);
|
|
182
|
+
srv.listen();
|
|
183
|
+
console.log(`jsql-neo MySQL server on ${options.host}:${options.port}${options.dataDir ? ' (data: ' + options.dataDir + ')' : ' (in-memory)'}`);
|
|
184
|
+
} else {
|
|
185
|
+
const { createMultiServer } = require('../lib/multiserver');
|
|
186
|
+
const srv = createMultiServer(options);
|
|
187
|
+
srv.listen(() => {
|
|
188
|
+
console.log(`jsql-neo multi-protocol server on ${options.host}:${options.port}${options.dataDir ? ' (data: ' + options.dataDir + ')' : ' (in-memory)'}`);
|
|
189
|
+
console.log(' MySQL / PostgreSQL / MongoDB / Redis — one port, shared data');
|
|
190
|
+
});
|
|
191
|
+
const shutdown = () => srv.close(() => process.exit(0));
|
|
192
|
+
process.once('SIGINT', shutdown);
|
|
193
|
+
process.once('SIGTERM', shutdown);
|
|
194
|
+
}
|
|
175
195
|
setInterval(() => {}, 1 << 30);
|
|
176
196
|
})
|
|
177
197
|
.command('star', 'Confirm whether you starred this repo (opens browser once)', (sub) => {
|
|
@@ -189,7 +209,32 @@ const cli = yaggs({ pkg: require('../package.json') })
|
|
|
189
209
|
const nonceUrl = sn.startOauthCheck(); // 打开浏览器 + 后台轮询写回 ~/.jsqlmr
|
|
190
210
|
if (!process.stdout.isTTY) console.log(' ' + nonceUrl);
|
|
191
211
|
console.log('After you authorize, this config is updated: ' + sn.configPath());
|
|
192
|
-
console.log('
|
|
212
|
+
console.log('Waiting for authorization (up to 3 minutes)…');
|
|
213
|
+
// startOauthCheck 的定时器是 unref 的,命令必须自己前台等,否则接不到结果
|
|
214
|
+
const res = sn.waitForResult(nonceUrl, 180000);
|
|
215
|
+
const status = res && res.status;
|
|
216
|
+
if (status === 'starred') {
|
|
217
|
+
sn.setStarred(true);
|
|
218
|
+
console.log(`Confirmed: ${res.login} has starred ${res.repo}. Thanks!`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (status === 'not-starred') {
|
|
222
|
+
console.log(`Authorization finished, but ${res.login} has not starred ${res.repo} yet.`);
|
|
223
|
+
console.log(`Star it at ${sn.URL} then run \`jsql star\` again.`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (status === 'denied' || status === 'error') {
|
|
227
|
+
// 这不是「没 star」,是授权本身没走通(code 过期、链接被重复打开、被拒绝)。
|
|
228
|
+
// 老实现这里会把 login/repo 打印成 undefined,并让用户去 star —— 误导。
|
|
229
|
+
console.log(`Authorization ${status === 'denied' ? 'was declined' : 'failed'}: ${res.error || 'unknown reason'}`);
|
|
230
|
+
console.log('The link is single-use. Run `jsql star` again to get a fresh one.');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (status === 'expired') {
|
|
234
|
+
console.log('The link expired. Run `jsql star` again to retry.');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
console.log('Timed out. Run `jsql star` again to retry.');
|
|
193
238
|
})
|
|
194
239
|
.command('bench', 'Insert + query benchmark against a data directory', (sub) => {
|
|
195
240
|
sub.option('n', { type: 'number', description: 'Number of rows (default 10000)' });
|
package/index.js
CHANGED
package/lib/mongo_server.js
CHANGED
|
@@ -155,20 +155,28 @@ class MongoServer {
|
|
|
155
155
|
this._server = null;
|
|
156
156
|
this._sockets = new Set();
|
|
157
157
|
this._dbCollections = new Map(); // db name → Set of collection names
|
|
158
|
+
this._engineByDb = new Map(); // db name → Database (standalone 模式)
|
|
158
159
|
}
|
|
159
160
|
|
|
160
|
-
async _getEngine() {
|
|
161
|
+
async _getEngine(db = null) {
|
|
162
|
+
if (this._engineByDb && db) {
|
|
163
|
+
if (!this._engineByDb.has(db)) this._engineByDb.set(db, this._createEngine(db));
|
|
164
|
+
return this._engineByDb.get(db);
|
|
165
|
+
}
|
|
161
166
|
if (this._engine) return this._engine;
|
|
167
|
+
this._engine = this._createEngine(null);
|
|
168
|
+
return this._engine;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_createEngine(db) {
|
|
162
172
|
if (this.dataDir && this.dataDir !== ':memory:') {
|
|
163
|
-
|
|
164
|
-
} else {
|
|
165
|
-
this._engine = new Database(':memory:', { autoSave: false });
|
|
173
|
+
return new Database(path.join(this.dataDir, db || 'default'), { autoSave: true });
|
|
166
174
|
}
|
|
167
|
-
return
|
|
175
|
+
return new Database(':memory:', { autoSave: false });
|
|
168
176
|
}
|
|
169
177
|
|
|
170
178
|
async _ensureCollection(name, db = null) {
|
|
171
|
-
const engine = await this._getEngine();
|
|
179
|
+
const engine = await this._getEngine(db);
|
|
172
180
|
if (!engine.getTableSchema(name)) {
|
|
173
181
|
engine.createTable(name, {});
|
|
174
182
|
}
|
|
@@ -190,25 +198,35 @@ class MongoServer {
|
|
|
190
198
|
socket.on('close', () => this._sockets.delete(socket));
|
|
191
199
|
let buf = Buffer.isBuffer(existing) ? Buffer.from(existing) : Buffer.alloc(0);
|
|
192
200
|
socket.on('error', () => {});
|
|
201
|
+
let processing = false;
|
|
202
|
+
let again = false;
|
|
193
203
|
const processLoop = async () => {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
+
if (processing) { again = true; return; }
|
|
205
|
+
processing = true;
|
|
206
|
+
try {
|
|
207
|
+
for (;;) {
|
|
208
|
+
if (buf.length < 4) break;
|
|
209
|
+
const len = buf.readInt32LE(0);
|
|
210
|
+
if (len < 16 || len > 48 * 1024 * 1024) break;
|
|
211
|
+
if (buf.length < len) break;
|
|
212
|
+
const msg = buf.slice(0, len);
|
|
213
|
+
buf = buf.slice(len);
|
|
214
|
+
try {
|
|
215
|
+
await this._handleMessage(socket, msg);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
try { socket.write(this._wrapMsg(bsonDocument({ ok: 0, errmsg: e.message || String(e) }), this._reqId(msg))); } catch (_) {}
|
|
218
|
+
}
|
|
204
219
|
}
|
|
220
|
+
} finally {
|
|
221
|
+
processing = false;
|
|
222
|
+
if (again) { again = false; processLoop(); }
|
|
205
223
|
}
|
|
206
224
|
};
|
|
207
|
-
socket.on('data',
|
|
225
|
+
socket.on('data', (chunk) => {
|
|
208
226
|
buf = Buffer.concat([buf, chunk]);
|
|
209
|
-
|
|
227
|
+
processLoop();
|
|
210
228
|
});
|
|
211
|
-
if (buf.length > 0)
|
|
229
|
+
if (buf.length > 0) processLoop();
|
|
212
230
|
}
|
|
213
231
|
|
|
214
232
|
_reqId(msg) {
|
|
@@ -272,13 +290,45 @@ class MongoServer {
|
|
|
272
290
|
socket.write(this._wrapMsg(bsonDocument({ ok: 0, errmsg: `opCode ${op2} not supported` }), this._reqId(msg)));
|
|
273
291
|
return;
|
|
274
292
|
}
|
|
293
|
+
const flags = msg.readInt32LE(16);
|
|
294
|
+
const msgEnd = (flags & 1) ? msg.length - 4 : msg.length; // checksumPresent
|
|
275
295
|
let pos = 16 + 4; // header + flagBits
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
296
|
+
const body = {};
|
|
297
|
+
let sawBody = false;
|
|
298
|
+
while (pos < msgEnd) {
|
|
299
|
+
const kind = msg[pos++];
|
|
300
|
+
if (kind === 0) {
|
|
301
|
+
const r = bsonReadDocument(msg, pos);
|
|
302
|
+
Object.assign(body, r.value);
|
|
303
|
+
pos = r.pos;
|
|
304
|
+
sawBody = true;
|
|
305
|
+
} else if (kind === 1) {
|
|
306
|
+
// Document sequence: int32 size, cstring identifier, BSON docs
|
|
307
|
+
const size = msg.readInt32LE(pos);
|
|
308
|
+
const end = pos + size;
|
|
309
|
+
if (size < 5 || end > msgEnd) throw new Error('malformed document sequence');
|
|
310
|
+
let p = pos + 4;
|
|
311
|
+
const nameEnd = msg.indexOf(0, p);
|
|
312
|
+
if (nameEnd < 0 || nameEnd >= end) throw new Error('malformed document sequence identifier');
|
|
313
|
+
const ident = msg.toString('utf8', p, nameEnd);
|
|
314
|
+
p = nameEnd + 1;
|
|
315
|
+
const seq = [];
|
|
316
|
+
while (p < end) {
|
|
317
|
+
const r = bsonReadDocument(msg, p);
|
|
318
|
+
seq.push(r.value);
|
|
319
|
+
p = r.pos;
|
|
320
|
+
}
|
|
321
|
+
body[ident] = seq;
|
|
322
|
+
pos = end;
|
|
323
|
+
} else {
|
|
324
|
+
socket.write(this._wrapMsg(bsonDocument({ ok: 0, errmsg: `section kind ${kind} not supported` }), this._reqId(msg)));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (!sawBody) {
|
|
329
|
+
socket.write(this._wrapMsg(bsonDocument({ ok: 0, errmsg: 'missing OP_MSG body section' }), this._reqId(msg)));
|
|
279
330
|
return;
|
|
280
331
|
}
|
|
281
|
-
const body = bsonReadDocument(msg, pos).value;
|
|
282
332
|
const command = this._commandName(body);
|
|
283
333
|
const reply = await this._dispatch(command, body);
|
|
284
334
|
socket.write(this._wrapMsg(bsonDocument(reply), this._reqId(msg)));
|
|
@@ -332,13 +382,13 @@ class MongoServer {
|
|
|
332
382
|
case 'endSessions': return { ok: 1 };
|
|
333
383
|
case 'listDatabases': {
|
|
334
384
|
// P3-17: listDatabases 只返回当前库——返回所有数据库
|
|
335
|
-
const engine = await this._getEngine();
|
|
385
|
+
const engine = await this._getEngine(this._dbOf(doc));
|
|
336
386
|
const dbs = Object.keys(engine._meta && engine._meta.databases ? engine._meta.databases : { [this._dbOf(doc)]: 1 });
|
|
337
387
|
const databases = Object.keys(dbs).map(name => ({ name, sizeOnDisk: 1, empty: false }));
|
|
338
388
|
return { ok: 1, databases, totalSize: databases.length };
|
|
339
389
|
}
|
|
340
390
|
case 'listCollections': {
|
|
341
|
-
const engine = await this._getEngine();
|
|
391
|
+
const engine = await this._getEngine(this._dbOf(doc));
|
|
342
392
|
const tables = Object.keys(engine._meta && engine._meta.tables ? engine._meta.tables : (engine._tables || {}));
|
|
343
393
|
const firstBatch = tables.map((t) => ({ name: t, type: 'collection', options: {}, info: { readOnly: false, uuid: '' } }));
|
|
344
394
|
return { ok: 1, cursor: { id: { $long: 0 }, ns: `${this._dbOf(doc)}.$cmd.listCollections`, firstBatch } };
|
|
@@ -349,7 +399,7 @@ class MongoServer {
|
|
|
349
399
|
}
|
|
350
400
|
case 'drop': {
|
|
351
401
|
try {
|
|
352
|
-
const engine = await this._getEngine();
|
|
402
|
+
const engine = await this._getEngine(this._dbOf(doc));
|
|
353
403
|
engine.dropTable(String(doc.drop));
|
|
354
404
|
return { ok: 1, ns: `${this._dbOf(doc)}.${doc.drop}` };
|
|
355
405
|
} catch (e) {
|
|
@@ -365,10 +415,11 @@ class MongoServer {
|
|
|
365
415
|
const doc2 = { ...d };
|
|
366
416
|
// 检查 _id 唯一性
|
|
367
417
|
if (doc2._id !== undefined) {
|
|
368
|
-
const existing = engine.find(coll, { _id: doc2._id });
|
|
418
|
+
const existing = await engine.find(coll, { _id: doc2._id });
|
|
369
419
|
if (existing.length > 0) continue; // 跳过重复 _id
|
|
370
420
|
}
|
|
371
|
-
|
|
421
|
+
await engine.insert(coll, doc2);
|
|
422
|
+
ids.push(doc2._id);
|
|
372
423
|
}
|
|
373
424
|
return { ok: 1, n: ids.length };
|
|
374
425
|
}
|
|
@@ -376,23 +427,23 @@ class MongoServer {
|
|
|
376
427
|
const coll = String(doc.findAndModify);
|
|
377
428
|
const engine = await this._ensureCollection(coll, this._dbOf(doc));
|
|
378
429
|
const q = doc.query || {};
|
|
379
|
-
const rows = this._matching(engine, coll, q);
|
|
430
|
+
const rows = await this._matching(engine, coll, q);
|
|
380
431
|
const found = rows[0] || null;
|
|
381
432
|
const set = doc.update && doc.update.$set ? doc.update.$set : (doc.update || {});
|
|
382
433
|
let value = null;
|
|
383
434
|
if (found) {
|
|
384
435
|
if (doc.remove) {
|
|
385
|
-
this._removeRows(engine, coll, [found]);
|
|
436
|
+
await this._removeRows(engine, coll, [found]);
|
|
386
437
|
value = found;
|
|
387
438
|
} else {
|
|
388
439
|
const patched = { ...found, ...set };
|
|
389
|
-
this._patchRows(engine, coll, [found], set);
|
|
440
|
+
await this._patchRows(engine, coll, [found], set);
|
|
390
441
|
value = doc.new ? patched : found;
|
|
391
442
|
}
|
|
392
443
|
} else if (doc.upsert) {
|
|
393
444
|
const merged = { ...q, ...set };
|
|
394
|
-
|
|
395
|
-
value = doc.new ? { ...merged
|
|
445
|
+
await engine.insert(coll, merged);
|
|
446
|
+
value = doc.new ? { ...merged } : null;
|
|
396
447
|
}
|
|
397
448
|
return { ok: 1, value, lastErrorObject: { n: found ? 1 : (doc.upsert ? 1 : 0), updatedExisting: !!found && !doc.remove } };
|
|
398
449
|
}
|
|
@@ -400,7 +451,7 @@ class MongoServer {
|
|
|
400
451
|
const coll = String(doc.distinct);
|
|
401
452
|
const engine = await this._ensureCollection(coll, this._dbOf(doc));
|
|
402
453
|
const key = String(doc.key || '');
|
|
403
|
-
const rows = this._matching(engine, coll, doc.query);
|
|
454
|
+
const rows = await this._matching(engine, coll, doc.query);
|
|
404
455
|
const seen = new Set();
|
|
405
456
|
const values = [];
|
|
406
457
|
for (const r of rows) {
|
|
@@ -411,7 +462,7 @@ class MongoServer {
|
|
|
411
462
|
}
|
|
412
463
|
case 'dropDatabase': {
|
|
413
464
|
const dbName = String(doc.$db || 'test');
|
|
414
|
-
const engine = await this._getEngine();
|
|
465
|
+
const engine = await this._getEngine(dbName);
|
|
415
466
|
const colls = this._dbCollections.get(dbName);
|
|
416
467
|
if (colls) {
|
|
417
468
|
for (const c of colls) {
|
|
@@ -424,7 +475,7 @@ class MongoServer {
|
|
|
424
475
|
case 'find': {
|
|
425
476
|
const coll = String(doc.find);
|
|
426
477
|
const engine = await this._ensureCollection(coll, this._dbOf(doc));
|
|
427
|
-
let rows = this._matching(engine, coll, doc.filter);
|
|
478
|
+
let rows = await this._matching(engine, coll, doc.filter);
|
|
428
479
|
const skip = typeof doc.skip === 'number' ? doc.skip : 0;
|
|
429
480
|
if (skip > 0) rows = rows.slice(skip);
|
|
430
481
|
const limit = typeof doc.limit === 'number' ? doc.limit : 0;
|
|
@@ -442,24 +493,24 @@ class MongoServer {
|
|
|
442
493
|
const q = u.q || {};
|
|
443
494
|
const set = u.u && u.u.$set ? u.u.$set : (u.u || {});
|
|
444
495
|
const multi = u.multi === true;
|
|
445
|
-
let targets = this._matching(engine, coll, q);
|
|
496
|
+
let targets = await this._matching(engine, coll, q);
|
|
446
497
|
if (!targets.length && u.upsert) {
|
|
447
498
|
const merged = { ...q, ...set };
|
|
448
|
-
engine.insert(coll, merged);
|
|
499
|
+
await engine.insert(coll, merged);
|
|
449
500
|
n++;
|
|
450
501
|
continue;
|
|
451
502
|
}
|
|
452
503
|
if (multi) {
|
|
453
504
|
// 更新所有匹配的行
|
|
454
505
|
for (const target of targets) {
|
|
455
|
-
this._patchRows(engine, coll, [target], u.u || { $set: set });
|
|
506
|
+
await this._patchRows(engine, coll, [target], u.u || { $set: set });
|
|
456
507
|
n++;
|
|
457
508
|
}
|
|
458
509
|
} else {
|
|
459
510
|
// 只更新第一条
|
|
460
511
|
let target = targets[0] || null;
|
|
461
512
|
if (target) {
|
|
462
|
-
this._patchRows(engine, coll, [target], u.u || { $set: set });
|
|
513
|
+
await this._patchRows(engine, coll, [target], u.u || { $set: set });
|
|
463
514
|
n++;
|
|
464
515
|
}
|
|
465
516
|
}
|
|
@@ -471,10 +522,10 @@ class MongoServer {
|
|
|
471
522
|
const engine = await this._ensureCollection(coll, this._dbOf(doc));
|
|
472
523
|
let n = 0;
|
|
473
524
|
for (const d of (doc.deletes || [])) {
|
|
474
|
-
const rows = this._matching(engine, coll, d.q || {});
|
|
525
|
+
const rows = await this._matching(engine, coll, d.q || {});
|
|
475
526
|
// MongoDB delete: limit=1 means delete only one, limit=0 means delete all
|
|
476
527
|
const toDelete = d.limit === 1 ? rows.slice(0, 1) : rows;
|
|
477
|
-
if (toDelete.length > 0) this._removeRows(engine, coll, toDelete);
|
|
528
|
+
if (toDelete.length > 0) await this._removeRows(engine, coll, toDelete);
|
|
478
529
|
n += toDelete.length;
|
|
479
530
|
}
|
|
480
531
|
return { ok: 1, n };
|
|
@@ -482,14 +533,14 @@ class MongoServer {
|
|
|
482
533
|
case 'count': {
|
|
483
534
|
const coll = String(doc.count);
|
|
484
535
|
const engine = await this._ensureCollection(coll, this._dbOf(doc));
|
|
485
|
-
const rows = this._matching(engine, coll, doc.query);
|
|
536
|
+
const rows = await this._matching(engine, coll, doc.query);
|
|
486
537
|
return { ok: 1, n: Number(doc.limit) > 0 ? Math.min(rows.length, doc.limit) : rows.length };
|
|
487
538
|
}
|
|
488
539
|
case 'aggregate': {
|
|
489
540
|
const coll = String(doc.aggregate);
|
|
490
541
|
const engine = await this._ensureCollection(coll, this._dbOf(doc));
|
|
491
542
|
const pipeline = doc.pipeline || [];
|
|
492
|
-
let rows = engine.find(coll, {});
|
|
543
|
+
let rows = await engine.find(coll, {});
|
|
493
544
|
for (const stage of pipeline) {
|
|
494
545
|
if (stage.$match) rows = rows.filter((r) => this._match(r, stage.$match));
|
|
495
546
|
else if (stage.$count) rows = [{ [stage.$count]: rows.length }];
|
|
@@ -613,11 +664,19 @@ class MongoServer {
|
|
|
613
664
|
return row._id != null ? row._id : row.id;
|
|
614
665
|
}
|
|
615
666
|
|
|
616
|
-
_matching(engine, coll, filter) {
|
|
617
|
-
|
|
667
|
+
async _matching(engine, coll, filter) {
|
|
668
|
+
const rows = await engine.find(coll, {});
|
|
669
|
+
return rows.filter((r) => this._match(r, filter || {}));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
_rowKey(engine, coll, row) {
|
|
673
|
+
const table = engine._tables && engine._tables[coll];
|
|
674
|
+
const pk = table && (table._primaryKey || table._autoIncrementField);
|
|
675
|
+
// 有主键用主键值;Mongo 自建集合没有主键,传行对象走深度匹配
|
|
676
|
+
return pk ? row[pk] : { ...row };
|
|
618
677
|
}
|
|
619
678
|
|
|
620
|
-
_patchRows(engine, coll, rows, updateDoc) {
|
|
679
|
+
async _patchRows(engine, coll, rows, updateDoc) {
|
|
621
680
|
const entries = [];
|
|
622
681
|
// 检查是否有更新操作符
|
|
623
682
|
const operators = ['$inc', '$unset', '$push', '$set'];
|
|
@@ -626,14 +685,14 @@ class MongoServer {
|
|
|
626
685
|
if (!hasOperators) {
|
|
627
686
|
// 没有操作符,直接 set
|
|
628
687
|
for (const r of rows) {
|
|
629
|
-
const
|
|
630
|
-
if (
|
|
688
|
+
const key = this._rowKey(engine, coll, r);
|
|
689
|
+
if (key !== undefined) entries.push([key, { ...updateDoc }]);
|
|
631
690
|
}
|
|
632
691
|
} else {
|
|
633
692
|
// 有操作符,需要特殊处理
|
|
634
693
|
for (const r of rows) {
|
|
635
|
-
const
|
|
636
|
-
if (
|
|
694
|
+
const key = this._rowKey(engine, coll, r);
|
|
695
|
+
if (key === undefined) continue;
|
|
637
696
|
const update = {};
|
|
638
697
|
if (updateDoc.$set) Object.assign(update, updateDoc.$set);
|
|
639
698
|
if (updateDoc.$inc) {
|
|
@@ -654,21 +713,21 @@ class MongoServer {
|
|
|
654
713
|
update[field] = null;
|
|
655
714
|
}
|
|
656
715
|
}
|
|
657
|
-
if (Object.keys(update).length > 0) entries.push([
|
|
716
|
+
if (Object.keys(update).length > 0) entries.push([key, update]);
|
|
658
717
|
}
|
|
659
718
|
}
|
|
660
|
-
if (entries.length > 0) engine.updateByIds(coll, entries);
|
|
719
|
+
if (entries.length > 0) await engine.updateByIds(coll, entries);
|
|
661
720
|
}
|
|
662
721
|
|
|
663
|
-
_removeRows(engine, coll, rows) {
|
|
722
|
+
async _removeRows(engine, coll, rows) {
|
|
664
723
|
// 收集所有需要删除的行的 id,走 engine.removeByIds
|
|
665
724
|
// 这样会写 WAL、触发 hooks、检查约束
|
|
666
725
|
const ids = [];
|
|
667
726
|
for (const r of rows) {
|
|
668
|
-
const
|
|
669
|
-
if (
|
|
727
|
+
const key = this._rowKey(engine, coll, r);
|
|
728
|
+
if (key !== undefined) ids.push(key);
|
|
670
729
|
}
|
|
671
|
-
if (ids.length > 0) engine.removeByIds(coll, ids);
|
|
730
|
+
if (ids.length > 0) await engine.removeByIds(coll, ids);
|
|
672
731
|
}
|
|
673
732
|
|
|
674
733
|
_match(row, filter) {
|
|
@@ -729,7 +788,11 @@ class MongoServer {
|
|
|
729
788
|
if (this._server) { this._server.close(cb || (() => {})); this._server = null; }
|
|
730
789
|
else if (cb) cb();
|
|
731
790
|
};
|
|
732
|
-
|
|
791
|
+
const engines = new Set(this._engineByDb ? this._engineByDb.values() : []);
|
|
792
|
+
if (this._engine) engines.add(this._engine);
|
|
793
|
+
const stops = [];
|
|
794
|
+
for (const e of engines) if (e && typeof e.stop === 'function') stops.push(e.stop());
|
|
795
|
+
if (stops.length > 0) Promise.allSettled(stops).then(done).catch(done);
|
|
733
796
|
else done();
|
|
734
797
|
return this;
|
|
735
798
|
}
|
package/lib/multiserver.js
CHANGED
|
@@ -42,6 +42,10 @@ class MultiServer {
|
|
|
42
42
|
this.port = options.port ?? 5432;
|
|
43
43
|
this.host = options.host || '127.0.0.1';
|
|
44
44
|
this.dataDir = options.dataDir || null;
|
|
45
|
+
// 静默连接的兜底协议:MySQL 客户端等待服务器先发握手包,
|
|
46
|
+
// 而空闲的 Redis 连接也不主动发包,只能按配置的二选一处理。
|
|
47
|
+
this.idleProtocol = options.idleProtocol || 'mysql';
|
|
48
|
+
this.sniffTimeout = options.sniffTimeout != null ? options.sniffTimeout : 200;
|
|
45
49
|
// 共享 SQL 引擎:三种 SQL 协议读写同一份数据
|
|
46
50
|
this._engines = new Map();
|
|
47
51
|
const getEngine = async (dbName) => {
|
|
@@ -72,13 +76,17 @@ class MultiServer {
|
|
|
72
76
|
listen(cb) {
|
|
73
77
|
this._server = net.createServer((socket) => {
|
|
74
78
|
this._sockets.add(socket);
|
|
79
|
+
socket.on('error', () => {});
|
|
75
80
|
socket.on('close', () => this._sockets.delete(socket));
|
|
76
81
|
let first = Buffer.alloc(0);
|
|
77
82
|
let routed = false;
|
|
78
|
-
// MySQL 客户端会先等服务器握手包,不主动发字节;超时后按
|
|
83
|
+
// MySQL 客户端会先等服务器握手包,不主动发字节;超时后按 idleProtocol 处理
|
|
79
84
|
const t = setTimeout(() => {
|
|
80
|
-
if (
|
|
81
|
-
|
|
85
|
+
if (routed) return;
|
|
86
|
+
routed = true;
|
|
87
|
+
socket.removeListener('data', sniff);
|
|
88
|
+
try { this._route(socket, first); } catch (e) { socket.destroy(); }
|
|
89
|
+
}, this.sniffTimeout);
|
|
82
90
|
t.unref();
|
|
83
91
|
const sniff = (chunk) => {
|
|
84
92
|
first = Buffer.concat([first, chunk]);
|
|
@@ -104,7 +112,7 @@ class MultiServer {
|
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
_route(socket, first) {
|
|
107
|
-
const proto = sniffProtocol(first) ||
|
|
115
|
+
const proto = sniffProtocol(first) || this.idleProtocol;
|
|
108
116
|
if (proto === 'mysql') {
|
|
109
117
|
const conn = new MysqlConnection(socket, this.mysql);
|
|
110
118
|
conn._onData(first);
|
package/lib/star-nag.js
CHANGED
|
@@ -24,7 +24,9 @@ const REPO = 'vexify-org/JSQL-neo';
|
|
|
24
24
|
const URL = `https://github.com/${REPO}`;
|
|
25
25
|
const NAG_INTERVAL_DAYS = 30;
|
|
26
26
|
|
|
27
|
-
const AUTH_BASE = process.env.JSQL_AUTH_BASE || 'https://auth.vexify.top
|
|
27
|
+
const AUTH_BASE = process.env.JSQL_AUTH_BASE || 'https://auth.vexify.top';
|
|
28
|
+
const AUTH_START = AUTH_BASE + '/start.php';
|
|
29
|
+
const AUTH_RESULT = AUTH_BASE + '/result.php';
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* 发起 OAuth 浏览器流并在后台轮询结果。
|
|
@@ -33,10 +35,28 @@ const AUTH_BASE = process.env.JSQL_AUTH_BASE || 'https://auth.vexify.top/callbac
|
|
|
33
35
|
* 拿到结果写进 ~/.jsqlmr(starred: true/false)。
|
|
34
36
|
* 进程提前退出就放弃这一次,下次启动会再提示(30 天节流管频率)。
|
|
35
37
|
*/
|
|
38
|
+
// result.php 的合法终态:done / denied / error。
|
|
39
|
+
// 只有终态才写配置——"started" 这一类中间态写进去只会污染 ~/.jsqlmr。
|
|
40
|
+
const TERMINAL_STATES = new Set(['done', 'denied', 'error']);
|
|
41
|
+
|
|
42
|
+
// 把 result.php 的响应收敛成规范状态。老服务端会把 started 也报成 done,
|
|
43
|
+
// 缺少 starred 字段 —— 一律当未完成处理,让调用方继续等。
|
|
44
|
+
function parseAuthResult(j) {
|
|
45
|
+
if (!j || typeof j !== 'object') return null;
|
|
46
|
+
const st = j.state;
|
|
47
|
+
if (!TERMINAL_STATES.has(st)) return null;
|
|
48
|
+
const r = j.result || {};
|
|
49
|
+
if (st === 'done') {
|
|
50
|
+
if (typeof r.starred !== 'boolean') return null;
|
|
51
|
+
return { status: r.starred ? 'starred' : 'not-starred', login: r.login, repo: r.repo };
|
|
52
|
+
}
|
|
53
|
+
return { status: st, error: r.error || (st === 'denied' ? 'authorization denied' : 'authorization failed') };
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
function startOauthCheck() {
|
|
37
57
|
const crypto = require('crypto');
|
|
38
58
|
const nonce = crypto.randomBytes(24).toString('base64url');
|
|
39
|
-
const url =
|
|
59
|
+
const url = AUTH_START + '?nonce=' + nonce;
|
|
40
60
|
|
|
41
61
|
// 开浏览器:三平台各一个命令;失败就只打印链接让人手动点
|
|
42
62
|
const cmd = process.platform === 'win32' ? 'start'
|
|
@@ -54,19 +74,20 @@ function startOauthCheck() {
|
|
|
54
74
|
const t = setInterval(() => {
|
|
55
75
|
if (++tries > 45) { clearInterval(t); return; }
|
|
56
76
|
const { execFile } = require('child_process');
|
|
57
|
-
execFile('curl', ['-sS', '-m', '5',
|
|
77
|
+
execFile('curl', ['-sS', '-m', '5', AUTH_RESULT + '?nonce=' + nonce],
|
|
58
78
|
{ timeout: 6000, encoding: 'utf8' }, (err, stdout) => {
|
|
59
79
|
if (err) return;
|
|
60
80
|
let j = null;
|
|
61
81
|
try { j = JSON.parse(stdout); } catch (e) { return; }
|
|
62
|
-
|
|
82
|
+
const res = parseAuthResult(j);
|
|
83
|
+
if (!res) return; // pending / started / 格式不符 —— 继续等
|
|
63
84
|
clearInterval(t);
|
|
64
85
|
const cfg = readConfig();
|
|
65
86
|
cfg.oauthCheckedAt = Date.now();
|
|
66
|
-
cfg.login =
|
|
67
|
-
cfg.starred =
|
|
87
|
+
cfg.login = res.login || undefined;
|
|
88
|
+
cfg.starred = res.status === 'starred';
|
|
68
89
|
if (cfg.starred) cfg.starredAt = Date.now();
|
|
69
|
-
writeConfig(cfg);
|
|
90
|
+
writeConfig(cfg);
|
|
70
91
|
});
|
|
71
92
|
}, 2000);
|
|
72
93
|
t.unref();
|
|
@@ -159,35 +180,37 @@ function checkStarred() {
|
|
|
159
180
|
* @param {boolean} [opts.force] 忽略节流,强制提示一次
|
|
160
181
|
* @returns {boolean} 是否真的打印了
|
|
161
182
|
*/
|
|
183
|
+
/**
|
|
184
|
+
* 在 stdout 上印一行 star 提示。
|
|
185
|
+
*
|
|
186
|
+
* 规则(用户明确要求):
|
|
187
|
+
* 1. **不强制** —— 只印文字,绝不自动弹浏览器。要确认请用户自己跑 `jsql star`。
|
|
188
|
+
* 2. **除 `version` 外的每个命令都印**,没有 30 天节流这类「只提醒一次」的设计。
|
|
189
|
+
* 3. `~/.jsqlmr` 记录是否 star 过 —— `starred: true` 是唯一的闭嘴开关
|
|
190
|
+
* (另外 `JSQL_NO_STAR_NAG=1` 可临时静音)。
|
|
191
|
+
*
|
|
192
|
+
* @returns {boolean} 是否真的印了
|
|
193
|
+
*/
|
|
162
194
|
function nag(opts = {}) {
|
|
163
195
|
const out = opts.out || process.stdout;
|
|
164
196
|
const cfg = readConfig();
|
|
165
197
|
|
|
198
|
+
// 已确认 star 过 → 永久闭嘴。jsqlmr 就是干这个的。
|
|
166
199
|
if (!opts.force && cfg.starred) return false;
|
|
167
200
|
|
|
168
|
-
|
|
169
|
-
if (!opts.force && last && Date.now() - last < NAG_INTERVAL_DAYS * 86400000) {
|
|
170
|
-
// 顺便有机会补记 star 状态(不打印)
|
|
171
|
-
const s = checkStarred();
|
|
172
|
-
if (s) { cfg.starred = true; writeConfig(cfg); }
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// 有 token 就先查,省得对已经 star 的人重复啰嗦
|
|
201
|
+
// 有凭据就先查一下,省得对已经 star 的人反复啰嗦(查不到就当没查,照印)
|
|
177
202
|
const starred = checkStarred();
|
|
178
203
|
if (starred) {
|
|
179
204
|
cfg.starred = true;
|
|
180
205
|
cfg.starredAt = Date.now();
|
|
181
206
|
writeConfig(cfg);
|
|
182
|
-
return false;
|
|
207
|
+
return false;
|
|
183
208
|
}
|
|
184
209
|
|
|
185
210
|
out.write(`\n If JSQL-NEO saved you time, a star helps others find it: ${URL}\n`);
|
|
186
|
-
out.write(`
|
|
187
|
-
|
|
188
|
-
cfg.lastNagAt = Date.now();
|
|
189
|
-
writeConfig(cfg);
|
|
211
|
+
out.write(` Run \`jsql star\` to confirm and stop seeing this (or set JSQL_NO_STAR_NAG=1 to hide)\n\n`);
|
|
190
212
|
|
|
213
|
+
// 只记录「有没有 star」,不再写 lastNagAt —— 没有节流,写它没有意义。
|
|
191
214
|
return true;
|
|
192
215
|
}
|
|
193
216
|
|
|
@@ -200,4 +223,60 @@ function setStarred(v) {
|
|
|
200
223
|
return cfg.starred;
|
|
201
224
|
}
|
|
202
225
|
|
|
203
|
-
|
|
226
|
+
/**
|
|
227
|
+
* 同步阻塞轮询授权结果,直到拿到 done / expired 或超时。
|
|
228
|
+
*
|
|
229
|
+
* `jsql star` 用:startOauthCheck 的定时器是 unref 的(不能拖住别的命令),
|
|
230
|
+
* 所以 star 命令必须自己前台等,否则授权完成后 CLI 早已退出,结果没人接。
|
|
231
|
+
*
|
|
232
|
+
* @param {string} startUrl startOauthCheck 返回的 URL(含 nonce)
|
|
233
|
+
* @param {number} [timeoutMs] 最长等待时间,默认 180 秒
|
|
234
|
+
* @returns {object|null} result 对象(含 login/starred/repo),超时或失败返回 null
|
|
235
|
+
*/
|
|
236
|
+
/**
|
|
237
|
+
* 前台等待授权结果(`jsql star` 用)。
|
|
238
|
+
*
|
|
239
|
+
* 返回规范化后的结果对象:
|
|
240
|
+
* { status: 'starred', login, repo } 已 star
|
|
241
|
+
* { status: 'not-starred', login, repo } 授权完成但没 star
|
|
242
|
+
* { status: 'denied', error } 用户在 GitHub 拒绝授权
|
|
243
|
+
* { status: 'error', error } 回调失败(code 过期/复用等)
|
|
244
|
+
* { status: 'expired' } 链接超龄被清理
|
|
245
|
+
* null 超时(timeoutMs 内没有终态)
|
|
246
|
+
*
|
|
247
|
+
* 老服务端会把 "started" 这类中间态也报成 done 且不带 starred 字段 ——
|
|
248
|
+
* parseAuthResult 一律判为未完成,继续轮询直到超时,而不是打印一段
|
|
249
|
+
* login/repo 全为 undefined 的假失败信息。
|
|
250
|
+
*/
|
|
251
|
+
function waitForResult(startUrl, timeoutMs = 180000) {
|
|
252
|
+
const { execFileSync } = require('child_process');
|
|
253
|
+
// 注意:本模块顶部有 const URL = 'https://github.com/...' 会遮蔽全局 URL 类,
|
|
254
|
+
// 必须显式从 url 模块取,否则 new URL(...) 抛 TypeError
|
|
255
|
+
const { URL: NodeURL } = require('url');
|
|
256
|
+
let nonce = '';
|
|
257
|
+
try {
|
|
258
|
+
nonce = new NodeURL(startUrl).searchParams.get('nonce') || '';
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
if (!nonce) return null;
|
|
263
|
+
|
|
264
|
+
const deadline = Date.now() + timeoutMs;
|
|
265
|
+
while (Date.now() < deadline) {
|
|
266
|
+
try {
|
|
267
|
+
const out = execFileSync('curl', ['-sS', '-m', '5', AUTH_RESULT + '?nonce=' + nonce],
|
|
268
|
+
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: 8000, encoding: 'utf8' });
|
|
269
|
+
let j = null;
|
|
270
|
+
try { j = JSON.parse(out); } catch (e) { j = null; }
|
|
271
|
+
const res = parseAuthResult(j);
|
|
272
|
+
if (res) return res;
|
|
273
|
+
if (j && j.state === 'expired') return { status: 'expired' };
|
|
274
|
+
} catch (e) {
|
|
275
|
+
// 网络抖动 / 超时 —— 继续等
|
|
276
|
+
}
|
|
277
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = { nag, checkStarred, startOauthCheck, waitForResult, parseAuthResult, readConfig, setStarred, configPath, REPO, URL };
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsql-neo",
|
|
3
|
-
"version": "6.5.
|
|
3
|
+
"version": "6.5.3",
|
|
4
4
|
"description": "JSQL-NEO — Rust-powered embedded database with WASM, REST API, B-Tree indexes, WAL, crash recovery",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -46,18 +46,20 @@
|
|
|
46
46
|
],
|
|
47
47
|
"scripts": {
|
|
48
48
|
"postinstall": "node postinstall.js",
|
|
49
|
-
"test": "node test/smoke.js && node test/native.test.js && node test/json-functions.test.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/query-opt.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/dml-constraints.test.js",
|
|
49
|
+
"test": "node test/smoke.js && node test/native.test.js && node test/json-functions.test.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/query-opt.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/dml-constraints.test.js && node test/star-nag.test.js",
|
|
50
50
|
"test:parser": "node test/sql-parser.test.js",
|
|
51
51
|
"test:btree": "node test/btree.test.js",
|
|
52
52
|
"test:native": "node test/native.test.js",
|
|
53
53
|
"test:regress": "node test/regress-5.1.0.js",
|
|
54
|
-
"test:all": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/native.test.js && node test/native-multi-instance.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js && node test/dml-constraints.test.js && node test/savepoint.test.js && node test/alter-table.test.js && node test/null-semantics.test.js && node test/identifier-keywords.test.js && node test/sql-semantics.test.js && node test/extra-coverage.test.js && node test/filter-compile.test.js && node test/recursive-cte.test.js && node test/index-ddl.test.js",
|
|
54
|
+
"test:all": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/native.test.js && node test/native-multi-instance.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js && node test/dml-constraints.test.js && node test/savepoint.test.js && node test/alter-table.test.js && node test/null-semantics.test.js && node test/identifier-keywords.test.js && node test/sql-semantics.test.js && node test/extra-coverage.test.js && node test/filter-compile.test.js && node test/recursive-cte.test.js && node test/index-ddl.test.js && node test/mongo-group.test.js && node test/multiprotocol.test.js",
|
|
55
55
|
"test:coverage": "c8 --all --include='lib/**' --exclude='lib/web_ui.js' --reporter=text --reporter=lcov node test/coverage.js",
|
|
56
56
|
"test:orms": "node examples/orms/run-all.js",
|
|
57
57
|
"test:dml": "node test/dml-constraints.test.js",
|
|
58
58
|
"test:cte": "node test/recursive-cte.test.js",
|
|
59
59
|
"test:index": "node test/index-ddl.test.js",
|
|
60
60
|
"test:mongo": "node test/mongo-group.test.js",
|
|
61
|
+
"test:multi": "node test/multiprotocol.test.js",
|
|
62
|
+
"test:star": "node test/star-nag.test.js",
|
|
61
63
|
"test:savepoint": "node test/savepoint.test.js",
|
|
62
64
|
"test:alter": "node test/alter-table.test.js",
|
|
63
65
|
"test:ident": "node test/identifier-keywords.test.js",
|
package/test/mongo-group.test.js
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
|
-
/*
|
|
2
|
-
*
|
|
3
|
-
* 现状:本文件会崩在 `rows is not iterable`(lib/mongo_server.js:541)。
|
|
4
|
-
* 另外 MongoDB 协议栈与**官方现代 driver 的握手尚未打通** ——
|
|
5
|
-
* 用官方 mongodb driver 连不上。
|
|
6
|
-
*
|
|
7
|
-
* 因此「四协议」目前实际是「一个半」:MySQL 可用;PG / Redis 以库形式
|
|
8
|
-
* 提供、需自行装配;MongoDB 仅供实验。
|
|
9
|
-
* 文档已按此更正(doc/why.md、doc/cli.md)。
|
|
10
|
-
*
|
|
11
|
-
* 修好前请勿把本文件加进 CI —— 它一崩就会像之前那样掐断 `&&` 链。
|
|
12
|
-
* 修 MongoDB 需要单独排期,不属于性能/打包类改动。
|
|
13
|
-
*/
|
|
1
|
+
/* Test: MongoDB $group aggregation operators
|
|
2
|
+
* Directly tests the _dispatch method of createMongoServer */
|
|
14
3
|
|
|
15
4
|
// Test: MongoDB $group aggregation operators
|
|
16
5
|
// Directly tests the _dispatch method of createMongoServer
|
|
@@ -26,9 +15,9 @@ const ok = (name, cond, extra) => {
|
|
|
26
15
|
const srv = createMongoServer({ port: 0, noAuth: true, dataDir: ':memory:' });
|
|
27
16
|
srv.listen(() => {
|
|
28
17
|
(async () => {
|
|
29
|
-
// Get the engine and pre-populate
|
|
30
|
-
const engine = await srv._getEngine();
|
|
31
|
-
engine.createTable('sales', {
|
|
18
|
+
// Get the engine and pre-populate(库名必须与 dispatch 的 $db 一致)
|
|
19
|
+
const engine = await srv._getEngine('test');
|
|
20
|
+
await engine.createTable('sales', { _id: { type: 'integer' }, item: { type: 'string' }, qty: { type: 'integer' }, price: { type: 'integer' } });
|
|
32
21
|
const table = engine._ensureTable('sales');
|
|
33
22
|
table._rows = [
|
|
34
23
|
{ _id: 1, item: 'A', qty: 10, price: 5 },
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 单端口多协议回归测试
|
|
3
|
+
*
|
|
4
|
+
* 校验 README 里「One Port」这条承诺:
|
|
5
|
+
* 1. 同一个 TCP 端口按首包嗅探到 MySQL / PostgreSQL / MongoDB / Redis
|
|
6
|
+
* 2. 静默连接(MySQL 客户端等服务器先握手)按 idleProtocol 兜底
|
|
7
|
+
* 3. `jsql serve` 默认启动多协议服务器,`--mysql-only` 保留单协议行为
|
|
8
|
+
*
|
|
9
|
+
* 全部使用原始 socket,不依赖 mysql2 / pg / mongodb / ioredis。
|
|
10
|
+
*/
|
|
11
|
+
const net = require('net');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { spawn } = require('child_process');
|
|
14
|
+
const { createMultiServer, sniffProtocol } = require('../lib/multiserver');
|
|
15
|
+
const { bsonDocument } = require('../lib/mongo_server');
|
|
16
|
+
|
|
17
|
+
let pass = 0, fail = 0;
|
|
18
|
+
function ok(name, cond, extra) {
|
|
19
|
+
if (cond) { pass++; console.log('[OK] ' + name); }
|
|
20
|
+
else { fail++; console.log('[FAIL] ' + name + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function connect(port) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const s = net.connect(port, '127.0.0.1', () => resolve(s));
|
|
26
|
+
s.on('error', reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readSome(socket, ms = 1500) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
let buf = Buffer.alloc(0);
|
|
33
|
+
const done = () => { clearTimeout(t); socket.removeListener('data', onData); resolve(buf); };
|
|
34
|
+
const onData = (c) => { buf = Buffer.concat([buf, c]); };
|
|
35
|
+
const t = setTimeout(done, ms);
|
|
36
|
+
socket.on('data', onData);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mongoHello() {
|
|
41
|
+
const payload = bsonDocument({ hello: 1, helloOk: true, $db: 'admin' });
|
|
42
|
+
const head = Buffer.alloc(16);
|
|
43
|
+
head.writeInt32LE(16 + 4 + 1 + payload.length, 0);
|
|
44
|
+
head.writeInt32LE(1, 4);
|
|
45
|
+
head.writeInt32LE(0, 8);
|
|
46
|
+
head.writeInt32LE(2013, 12);
|
|
47
|
+
return Buffer.concat([head, Buffer.alloc(4), Buffer.from([0]), payload]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function main() {
|
|
51
|
+
console.log('--- 首包嗅探 ---');
|
|
52
|
+
ok('PG StartupMessage 判为 pg', sniffProtocol(Buffer.from([0, 0, 0, 60, 0, 3, 0, 0])) === 'pg');
|
|
53
|
+
ok('MySQL 握手版本判为 mysql', sniffProtocol(Buffer.from([0x0a, 0x38, 0x2e, 0x30])) === 'mysql');
|
|
54
|
+
ok('RESP 数组判为 redis', sniffProtocol(Buffer.from('*1\r\n$4\r\nPING\r\n')) === 'redis');
|
|
55
|
+
{
|
|
56
|
+
const m = mongoHello();
|
|
57
|
+
ok('Mongo OP_MSG 判为 mongo', sniffProtocol(m) === 'mongo');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log('\n--- 同一端口四协议 ---');
|
|
61
|
+
const srv = createMultiServer({ port: 0, host: '127.0.0.1', noAuth: true });
|
|
62
|
+
await new Promise((r) => srv.listen(r));
|
|
63
|
+
const port = srv.address.port;
|
|
64
|
+
ok('监听成功', port > 0, port);
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
const s = await connect(port);
|
|
68
|
+
const buf = await readSome(s);
|
|
69
|
+
s.destroy();
|
|
70
|
+
ok('MySQL 静默连接收到握手包', buf.length > 5 && buf[4] === 0x0a, buf.slice(0, 8).toString('hex'));
|
|
71
|
+
ok('握手包含服务器版本', buf.includes(Buffer.from('jsql-neo')));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
const s = await connect(port);
|
|
76
|
+
const ssl = Buffer.alloc(8);
|
|
77
|
+
ssl.writeInt32BE(8, 0);
|
|
78
|
+
ssl.writeInt32BE(80877103, 4); // SSLRequest
|
|
79
|
+
s.write(ssl);
|
|
80
|
+
const buf = await readSome(s, 800);
|
|
81
|
+
s.destroy();
|
|
82
|
+
ok('PostgreSQL SSLRequest 被拒绝并回 N', buf.toString('utf8') === 'N', buf.toString('utf8'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
const s = await connect(port);
|
|
87
|
+
s.write(Buffer.from('PING\r\n'));
|
|
88
|
+
const buf = await readSome(s, 800);
|
|
89
|
+
s.destroy();
|
|
90
|
+
ok('Redis PING 返回 +PONG', buf.toString('utf8').startsWith('+PONG'), buf.toString('utf8'));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
const s = await connect(port);
|
|
95
|
+
s.write(mongoHello());
|
|
96
|
+
const buf = await readSome(s, 1500);
|
|
97
|
+
s.destroy();
|
|
98
|
+
ok('Mongo hello 返回 OP_MSG', buf.length >= 16 && buf.readInt32LE(12) === 2013);
|
|
99
|
+
ok('Mongo hello ok:1', buf.includes(Buffer.from('isWritablePrimary')));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log('\n--- 共享同一份 SQL 引擎 ---');
|
|
103
|
+
{
|
|
104
|
+
// 直接用 MT 服务器内部的引擎实例验证:Mongo 与 SQL 走同一个 Database
|
|
105
|
+
const eng = await srv.mysql._getEngine('shared');
|
|
106
|
+
const sameAsPg = (await srv.pg._getEngine('shared')) === eng;
|
|
107
|
+
const sameAsMongo = (await srv.mongo._getEngine('shared')) === eng;
|
|
108
|
+
ok('MySQL/PG/Mongo 取到同一个引擎', sameAsPg && sameAsMongo);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await new Promise((r) => srv.close(r));
|
|
112
|
+
|
|
113
|
+
console.log('\n--- idleProtocol 兜底 ---');
|
|
114
|
+
{
|
|
115
|
+
const redisIdle = createMultiServer({ port: 0, host: '127.0.0.1', idleProtocol: 'redis' });
|
|
116
|
+
await new Promise((r) => redisIdle.listen(r));
|
|
117
|
+
const s = await connect(redisIdle.address.port);
|
|
118
|
+
// 不主动发包:按 idleProtocol 走 Redis,随后发 PING 必须能回
|
|
119
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
120
|
+
s.write(Buffer.from('PING\r\n'));
|
|
121
|
+
const buf = await readSome(s, 800);
|
|
122
|
+
s.destroy();
|
|
123
|
+
ok('idleProtocol=redis 时空闲连接仍可用', buf.toString('utf8').startsWith('+PONG'), buf.toString('utf8'));
|
|
124
|
+
await new Promise((r) => redisIdle.close(r));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log('\n--- CLI serve 入口 ---');
|
|
128
|
+
{
|
|
129
|
+
const bin = path.join(__dirname, '..', 'bin', 'jsql');
|
|
130
|
+
const child = spawn(process.execPath, [bin, 'serve', '-p', '0'], {
|
|
131
|
+
env: { ...process.env, JSQL_NO_STAR_NAG: '1' },
|
|
132
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
133
|
+
});
|
|
134
|
+
let out = '';
|
|
135
|
+
child.stdout.on('data', (c) => { out += c; });
|
|
136
|
+
child.stderr.on('data', (c) => { out += c; });
|
|
137
|
+
const started = await new Promise((resolve) => {
|
|
138
|
+
const t = setTimeout(() => resolve(false), 5000);
|
|
139
|
+
const check = () => {
|
|
140
|
+
if (/multi-protocol server on/.test(out) || /serve failed/.test(out)) {
|
|
141
|
+
clearTimeout(t); resolve(/multi-protocol server on/.test(out));
|
|
142
|
+
} else setTimeout(check, 50);
|
|
143
|
+
};
|
|
144
|
+
check();
|
|
145
|
+
});
|
|
146
|
+
ok('jsql serve 默认启动多协议服务器', started, out.trim());
|
|
147
|
+
child.kill('SIGTERM');
|
|
148
|
+
await new Promise((r) => { child.on('exit', r); setTimeout(r, 1000); });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(`\nRESULT: ${pass} passed, ${fail} failed`);
|
|
152
|
+
if (fail > 0) process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsql star 授权流回归测试
|
|
3
|
+
*
|
|
4
|
+
* 背景:线上 result.php 曾把「state 文件存在」一律当成授权完成,
|
|
5
|
+
* 而 start.php 一开始就会写入 {"state":"started"}。于是 CLI 第二轮轮询
|
|
6
|
+
* 就拿到 started 当 done,打印出:
|
|
7
|
+
* Authorization finished, but undefined has not starred undefined yet.
|
|
8
|
+
* Star it at https://github.com/undefined then run `jsql star` again.
|
|
9
|
+
* 真相是授权根本没走完。
|
|
10
|
+
*
|
|
11
|
+
* 覆盖:
|
|
12
|
+
* 1. parseAuthResult 对各形态响应的归一化(中间态不得当成完成)
|
|
13
|
+
* 2. CLI 对 starred / not-starred / denied / error 的输出
|
|
14
|
+
* 3. 慢用户场景:先 pending 再完成,CLI 必须继续等而不是误判
|
|
15
|
+
* 4. waitForResult 的超时与 expired 收敛
|
|
16
|
+
*
|
|
17
|
+
* 注意:AUTH_RESULT 是 lib/star-nag 在 require 时从 JSQL_AUTH_BASE 读的,
|
|
18
|
+
* 所以必须先把 env 设好再 require(waitForResult 又用同步 curl,
|
|
19
|
+
* 本进程内的 server 会被饿死,所以 CLI 相关用例一律起子进程)。
|
|
20
|
+
*/
|
|
21
|
+
const http = require('http');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
const { spawn } = require('child_process');
|
|
26
|
+
|
|
27
|
+
const PORT = Number(process.env.JSQL_STAR_TEST_PORT) || 34711;
|
|
28
|
+
process.env.JSQL_AUTH_BASE = `http://127.0.0.1:${PORT}`;
|
|
29
|
+
|
|
30
|
+
const sn = require('../lib/star-nag');
|
|
31
|
+
|
|
32
|
+
let pass = 0, fail = 0;
|
|
33
|
+
function ok(name, cond, extra) {
|
|
34
|
+
if (cond) { pass++; console.log('[OK] ' + name); }
|
|
35
|
+
else { fail++; console.log('[FAIL] ' + name + (extra !== undefined ? ' → ' + JSON.stringify(extra) : '')); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 当前 result.php 要返回的内容;pollsLeft <= 0 时切到 after */
|
|
39
|
+
let scenario = { state: 'pending' };
|
|
40
|
+
let pollsLeft = 0;
|
|
41
|
+
let after = null;
|
|
42
|
+
|
|
43
|
+
const server = http.createServer((req, res) => {
|
|
44
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
45
|
+
if (new URL(req.url, 'http://127.0.0.1').pathname === '/result.php') {
|
|
46
|
+
if (pollsLeft > 0 && --pollsLeft === 0 && after) scenario = after;
|
|
47
|
+
res.end(JSON.stringify(scenario));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
res.end('{}');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function runCli(mrPath) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const bin = path.join(__dirname, '..', 'bin', 'jsql');
|
|
56
|
+
const child = spawn(process.execPath, [bin, 'star'], {
|
|
57
|
+
env: { ...process.env, JSQL_AUTH_BASE: `http://127.0.0.1:${PORT}`, JSQL_MR_PATH: mrPath, JSQL_NO_STAR_NAG: '1' },
|
|
58
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
59
|
+
});
|
|
60
|
+
let out = '';
|
|
61
|
+
child.stdout.on('data', (c) => { out += c; });
|
|
62
|
+
child.stderr.on('data', (c) => { out += c; });
|
|
63
|
+
const t = setTimeout(() => { try { child.kill(); } catch (e) {} resolve(out + '\n[TIMEOUT]'); }, 40000);
|
|
64
|
+
child.on('exit', () => { clearTimeout(t); resolve(out); });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function main() {
|
|
69
|
+
/* ---------- 1. parseAuthResult ---------- */
|
|
70
|
+
console.log('--- parseAuthResult:中间态不得当成完成 ---');
|
|
71
|
+
{
|
|
72
|
+
const P = sn.parseAuthResult;
|
|
73
|
+
ok('done + starred=true → starred', P({ state: 'done', result: { starred: true, login: 'u', repo: 'o/r' } }).status === 'starred');
|
|
74
|
+
ok('done + starred=false → not-starred', P({ state: 'done', result: { starred: false, login: 'u', repo: 'o/r' } }).status === 'not-starred');
|
|
75
|
+
ok('started 被误报成 done → 未完成', P({ state: 'done', result: { state: 'started', at: 1 } }) === null);
|
|
76
|
+
ok('pending → 未完成', P({ state: 'pending' }) === null);
|
|
77
|
+
ok('缺 result 的 done → 未完成', P({ state: 'done' }) === null);
|
|
78
|
+
ok('denied → 带原因', P({ state: 'denied', result: { error: 'access_denied' } }).status === 'denied');
|
|
79
|
+
ok('error → 带原因', P({ state: 'error', result: { error: 'The code passed is incorrect or expired.' } }).error === 'The code passed is incorrect or expired.');
|
|
80
|
+
ok('空响应 → 未完成', P(null) === null && P({}) === null);
|
|
81
|
+
ok('未知状态 → 未完成', P({ state: 'weird' }) === null);
|
|
82
|
+
ok('expired 不在终态集(由 waitForResult 单独处理)', P({ state: 'expired' }) === null);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await new Promise((r) => server.listen(PORT, '127.0.0.1', r));
|
|
86
|
+
const mrDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsqlmr-'));
|
|
87
|
+
|
|
88
|
+
/* ---------- 2. CLI 各终态输出 ---------- */
|
|
89
|
+
console.log('\n--- 假 result.php 驱动真实 CLI ---');
|
|
90
|
+
const cases = [
|
|
91
|
+
['done + starred', { state: 'done', result: { starred: true, login: 'octocat', repo: 'vexify-org/JSQL-neo' } },
|
|
92
|
+
'Confirmed: octocat has starred vexify-org/JSQL-neo. Thanks!'],
|
|
93
|
+
['done + 未 star', { state: 'done', result: { starred: false, login: 'octocat', repo: 'vexify-org/JSQL-neo' } },
|
|
94
|
+
'Authorization finished, but octocat has not starred vexify-org/JSQL-neo yet.'],
|
|
95
|
+
['denied', { state: 'denied', result: { error: 'access_denied' } },
|
|
96
|
+
'Authorization was declined: access_denied'],
|
|
97
|
+
['error(code 过期/复用)', { state: 'error', result: { error: 'The code passed is incorrect or expired.' } },
|
|
98
|
+
'Authorization failed: The code passed is incorrect or expired.'],
|
|
99
|
+
];
|
|
100
|
+
for (const [name, resp, expect] of cases) {
|
|
101
|
+
scenario = resp; pollsLeft = 0; after = null;
|
|
102
|
+
const out = await runCli(path.join(mrDir, name.replace(/[^a-z0-9]+/gi, '-')));
|
|
103
|
+
ok(`CLI 输出正确:${name}`, out.includes(expect), out.trim().slice(0, 200));
|
|
104
|
+
ok(`CLI 不再出现 undefined:${name}`, !/undefined/.test(out), out.trim().slice(0, 200));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ---------- 3. 慢用户 ---------- */
|
|
108
|
+
console.log('\n--- 慢用户:先 pending 再完成,CLI 必须继续等而不是误判 ---');
|
|
109
|
+
{
|
|
110
|
+
scenario = { state: 'pending' };
|
|
111
|
+
pollsLeft = 2;
|
|
112
|
+
after = { state: 'done', result: { starred: true, login: 'octocat', repo: 'vexify-org/JSQL-neo' } };
|
|
113
|
+
const out = await runCli(path.join(mrDir, 'slow'));
|
|
114
|
+
ok('等完 pending 后正确确认', out.includes('Confirmed: octocat has starred vexify-org/JSQL-neo. Thanks!'), out.trim().slice(0, 200));
|
|
115
|
+
ok('全程没有误判成完成', !/Authorization finished/.test(out), out.trim().slice(0, 200));
|
|
116
|
+
ok('全程没有 undefined', !/undefined/.test(out), out.trim().slice(0, 200));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ---------- 4. waitForResult ---------- */
|
|
120
|
+
console.log('\n--- waitForResult:中间态与超时 ---');
|
|
121
|
+
{
|
|
122
|
+
// waitForResult 用同步 curl,会阻塞本进程事件循环 —— server 必须留在父进程,
|
|
123
|
+
// 轮询放到子进程里跑,否则 parent 的 server 被饿死,curl 全部 -m 超时。
|
|
124
|
+
const url = `http://127.0.0.1:${PORT}/start.php?nonce=abcdefghijklmnopqrst`;
|
|
125
|
+
const poll = (resp, timeoutMs) => new Promise((resolve) => {
|
|
126
|
+
scenario = resp; pollsLeft = 0; after = null;
|
|
127
|
+
const code = [
|
|
128
|
+
`const sn = require(${JSON.stringify(path.join(__dirname, '..', 'lib', 'star-nag'))});`,
|
|
129
|
+
`const t = Date.now();`,
|
|
130
|
+
`const r = sn.waitForResult(process.env.TEST_URL, ${timeoutMs});`,
|
|
131
|
+
`process.stdout.write(JSON.stringify({ r, ms: Date.now() - t }));`,
|
|
132
|
+
].join('\n');
|
|
133
|
+
const c = spawn(process.execPath, ['-e', code],
|
|
134
|
+
{ env: { ...process.env, TEST_URL: url }, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
135
|
+
let out = '';
|
|
136
|
+
c.stdout.on('data', (d) => { out += d; });
|
|
137
|
+
c.on('exit', () => { try { resolve(JSON.parse(out)); } catch (e) { resolve({ r: null, ms: -1 }); } });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
for (const [name, resp] of [
|
|
141
|
+
['pending', { state: 'pending' }],
|
|
142
|
+
['老服务端误报 started 为 done', { state: 'done', result: { state: 'started', at: 1 } }],
|
|
143
|
+
]) {
|
|
144
|
+
const { r, ms } = await poll(resp, 3000);
|
|
145
|
+
ok(`waitForResult(${name}) 超时返回 null`, r === null, r);
|
|
146
|
+
ok(`确实等满了 3 秒`, ms >= 2800, ms);
|
|
147
|
+
}
|
|
148
|
+
{
|
|
149
|
+
const { r, ms } = await poll({ state: 'expired' }, 5000);
|
|
150
|
+
ok('waitForResult(expired) 立即收敛', !!r && r.status === 'expired', r);
|
|
151
|
+
ok('expired 没有拖到超时', ms >= 0 && ms < 2500, ms);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
server.close();
|
|
156
|
+
console.log(`\nRESULT: ${pass} passed, ${fail} failed`);
|
|
157
|
+
if (fail > 0) process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|