@blamejs/core 0.6.20 → 0.6.22
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/CHANGELOG.md +2 -0
- package/README.md +18 -10
- package/lib/archive.js +8 -7
- package/lib/bundler.js +8 -8
- package/lib/cache.js +105 -20
- package/lib/db-query.js +21 -2
- package/lib/db.js +15 -0
- package/lib/framework-schema.js +38 -6
- package/lib/http-client-cookie-jar.js +117 -17
- package/lib/http-client.js +4 -3
- package/lib/mail.js +5 -4
- package/lib/network-dns.js +138 -43
- package/lib/pagination.js +136 -76
- package/lib/parsers/index.js +16 -2
- package/lib/parsers/safe-ini.js +273 -0
- package/lib/queue-local.js +12 -1
- package/lib/vault/index.js +3 -3
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/http-client.js
CHANGED
|
@@ -98,8 +98,8 @@ var { FrameworkError } = require("./framework-error");
|
|
|
98
98
|
// node's TLS layer — see TLS_SESSION_RESUMPTION_NOTES below).
|
|
99
99
|
var _transports = new Map();
|
|
100
100
|
|
|
101
|
-
// TLS session resumption notes — what
|
|
102
|
-
//
|
|
101
|
+
// TLS session resumption notes — what's automatic vs. what's not
|
|
102
|
+
// exposed by Node's public API:
|
|
103
103
|
//
|
|
104
104
|
// keepAlive Agent (h1) / long-lived ClientHttp2Session (h2) means
|
|
105
105
|
// the WARM-CONNECTION case is zero-handshake — better than 0-RTT.
|
|
@@ -115,7 +115,8 @@ var _transports = new Map();
|
|
|
115
115
|
//
|
|
116
116
|
// QUIC/h3 changes this calculus: 0-RTT is a first-class feature
|
|
117
117
|
// built into the protocol, with replay protection at the QUIC
|
|
118
|
-
// layer.
|
|
118
|
+
// layer. The framework's `b.httpClient` is HTTP/1.1 + HTTP/2 only;
|
|
119
|
+
// operators wanting h3 wire their own client.
|
|
119
120
|
|
|
120
121
|
// Pool tuning for the HTTP-client transport cache. Keep-alive is
|
|
121
122
|
// shorter than the standalone pqc-agent default (1s vs 30s) because
|
package/lib/mail.js
CHANGED
|
@@ -403,10 +403,11 @@ function _buildRfc822(message) {
|
|
|
403
403
|
body = inner.body;
|
|
404
404
|
} else {
|
|
405
405
|
// multipart/mixed: first part is the body (single or alternative),
|
|
406
|
-
// subsequent parts are the attachments. Inline disposition +
|
|
407
|
-
// is interpreted correctly by every major client even
|
|
408
|
-
//
|
|
409
|
-
//
|
|
406
|
+
// subsequent parts are the attachments. Inline disposition +
|
|
407
|
+
// Content-ID is interpreted correctly by every major client even
|
|
408
|
+
// inside mixed. Operators needing strict-RFC-2387 multipart/related
|
|
409
|
+
// wrap the body via the mail.transports interface and pass a
|
|
410
|
+
// content-type override.
|
|
410
411
|
var mixedBoundary = _newBoundary("mixed");
|
|
411
412
|
headers.push('Content-Type: multipart/mixed; boundary="' + mixedBoundary + '"');
|
|
412
413
|
var parts = [];
|
package/lib/network-dns.js
CHANGED
|
@@ -136,7 +136,7 @@ function setCacheTtlMs(ms, negativeMs) {
|
|
|
136
136
|
|
|
137
137
|
function useDnsOverHttps(opts) {
|
|
138
138
|
opts = opts || {};
|
|
139
|
-
validateOpts(opts, ["provider", "url"], "dns.useDnsOverHttps");
|
|
139
|
+
validateOpts(opts, ["provider", "url", "method"], "dns.useDnsOverHttps");
|
|
140
140
|
var url = opts.url;
|
|
141
141
|
if (!url && opts.provider) {
|
|
142
142
|
var p = String(opts.provider).toLowerCase();
|
|
@@ -149,9 +149,15 @@ function useDnsOverHttps(opts) {
|
|
|
149
149
|
throw new DnsError("dns/bad-doh-url",
|
|
150
150
|
"dns.useDnsOverHttps: url must be an https:// string, got " + JSON.stringify(url));
|
|
151
151
|
}
|
|
152
|
-
|
|
152
|
+
var method = opts.method;
|
|
153
|
+
if (method !== undefined && method !== "GET" && method !== "POST") {
|
|
154
|
+
throw new DnsError("dns/bad-doh-method",
|
|
155
|
+
"dns.useDnsOverHttps: method must be 'GET' | 'POST' | undefined (auto), got " +
|
|
156
|
+
JSON.stringify(method));
|
|
157
|
+
}
|
|
158
|
+
STATE.doh = { url: url, method: method };
|
|
153
159
|
_clearCache();
|
|
154
|
-
_emitObs("network.dns.doh.set", { url: url });
|
|
160
|
+
_emitObs("network.dns.doh.set", { url: url, method: method || "auto" });
|
|
155
161
|
}
|
|
156
162
|
|
|
157
163
|
function useDnsOverTls(opts) {
|
|
@@ -244,22 +250,41 @@ function _decodeDnsAnswer(buf, qtype) {
|
|
|
244
250
|
return addrs;
|
|
245
251
|
}
|
|
246
252
|
|
|
253
|
+
// DoH GET URL length cap. RFC 8484 §4.1 says clients MAY use POST when
|
|
254
|
+
// the GET URL would exceed implementation limits. We pick 2048 bytes
|
|
255
|
+
// (a conservative ceiling well below RFC 7230's recommended 8 KB) so
|
|
256
|
+
// long DNS names (e.g. ESNI / SVCB record queries with operator-side
|
|
257
|
+
// hostname concatenation) fall back cleanly. Operator can force POST
|
|
258
|
+
// always with `useDnsOverHttps({ url, method: "POST" })`.
|
|
259
|
+
var DOH_GET_URL_MAX_BYTES = 2048;
|
|
260
|
+
|
|
247
261
|
async function _dohLookup(host, family) {
|
|
248
262
|
var qtype = family === 6 ? 28 : 1;
|
|
249
263
|
var enc = _encodeDnsQuery(host, qtype);
|
|
250
264
|
var b64 = enc.buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
251
|
-
var
|
|
252
|
-
var
|
|
265
|
+
var getUrl = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
|
|
266
|
+
var forcedMethod = STATE.doh.method;
|
|
267
|
+
var usePost = forcedMethod === "POST" || (!forcedMethod && getUrl.length > DOH_GET_URL_MAX_BYTES);
|
|
268
|
+
var u = new URL(STATE.doh.url);
|
|
253
269
|
return new Promise(function (resolve, reject) {
|
|
254
|
-
var
|
|
255
|
-
hostname:
|
|
256
|
-
port:
|
|
257
|
-
path:
|
|
258
|
-
method:
|
|
259
|
-
headers:
|
|
270
|
+
var reqOpts = {
|
|
271
|
+
hostname: u.hostname,
|
|
272
|
+
port: u.port || 443,
|
|
273
|
+
path: u.pathname + u.search,
|
|
274
|
+
method: usePost ? "POST" : "GET",
|
|
275
|
+
headers: {
|
|
276
|
+
"accept": "application/dns-message",
|
|
277
|
+
},
|
|
260
278
|
minVersion: "TLSv1.3",
|
|
261
|
-
ecdhCurve:
|
|
262
|
-
}
|
|
279
|
+
ecdhCurve: C.TLS_GROUP_CURVE_STR,
|
|
280
|
+
};
|
|
281
|
+
if (usePost) {
|
|
282
|
+
reqOpts.headers["content-type"] = "application/dns-message";
|
|
283
|
+
reqOpts.headers["content-length"] = enc.buf.length;
|
|
284
|
+
} else {
|
|
285
|
+
reqOpts.path = (new URL(getUrl)).pathname + (new URL(getUrl)).search;
|
|
286
|
+
}
|
|
287
|
+
var req = https.request(reqOpts, function (res) {
|
|
263
288
|
var chunks = [];
|
|
264
289
|
res.on("data", function (c) { chunks.push(c); });
|
|
265
290
|
res.on("end", function () {
|
|
@@ -274,45 +299,114 @@ async function _dohLookup(host, family) {
|
|
|
274
299
|
});
|
|
275
300
|
});
|
|
276
301
|
req.on("error", function (e) { reject(new DnsError("dns/doh-failed", "DoH request failed: " + e.message)); });
|
|
302
|
+
if (usePost) req.write(enc.buf);
|
|
277
303
|
req.end();
|
|
278
304
|
});
|
|
279
305
|
}
|
|
280
306
|
|
|
307
|
+
// DoT connection pool. Per-(host:port) cached TLS socket so successive
|
|
308
|
+
// lookups amortize the handshake. Sockets idle past the timeout are
|
|
309
|
+
// closed and removed; first lookup after expiry rebuilds. Each socket
|
|
310
|
+
// services one in-flight query at a time (DNS-over-TCP allows pipelining
|
|
311
|
+
// but the framework chooses serialization for simpler back-pressure).
|
|
312
|
+
var DOT_IDLE_TIMEOUT_MS = C.TIME.minutes(2);
|
|
313
|
+
var _dotPool = new Map(); // "host:port" → { sock, lastUsedAt, idle, queue }
|
|
314
|
+
|
|
315
|
+
function _dotPoolKey() {
|
|
316
|
+
return STATE.dot.host + ":" + STATE.dot.port;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function _dotConnect() {
|
|
320
|
+
var sock = tls.connect({
|
|
321
|
+
host: STATE.dot.host,
|
|
322
|
+
port: STATE.dot.port,
|
|
323
|
+
servername: STATE.dot.servername,
|
|
324
|
+
minVersion: "TLSv1.3",
|
|
325
|
+
ecdhCurve: C.TLS_GROUP_CURVE_STR,
|
|
326
|
+
});
|
|
327
|
+
sock.unref && sock.unref();
|
|
328
|
+
return sock;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function _dotEvict(key) {
|
|
332
|
+
var entry = _dotPool.get(key);
|
|
333
|
+
if (!entry) return;
|
|
334
|
+
try { entry.sock.destroy(); } catch (_e) {}
|
|
335
|
+
_dotPool.delete(key);
|
|
336
|
+
}
|
|
337
|
+
|
|
281
338
|
async function _dotLookup(host, family) {
|
|
282
339
|
var qtype = family === 6 ? 28 : 1;
|
|
283
340
|
var enc = _encodeDnsQuery(host, qtype);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
341
|
+
var key = _dotPoolKey();
|
|
342
|
+
var entry = _dotPool.get(key);
|
|
343
|
+
if (entry && (Date.now() - entry.lastUsedAt > DOT_IDLE_TIMEOUT_MS)) {
|
|
344
|
+
_dotEvict(key);
|
|
345
|
+
entry = null;
|
|
346
|
+
}
|
|
347
|
+
if (!entry) {
|
|
348
|
+
var sock = _dotConnect();
|
|
349
|
+
entry = {
|
|
350
|
+
sock: sock,
|
|
351
|
+
lastUsedAt: Date.now(),
|
|
352
|
+
idle: true,
|
|
353
|
+
ready: new Promise(function (res, rej) {
|
|
354
|
+
sock.once("secureConnect", function () { res(); });
|
|
355
|
+
sock.once("error", function (e) { rej(e); });
|
|
356
|
+
}),
|
|
357
|
+
};
|
|
358
|
+
_dotPool.set(key, entry);
|
|
359
|
+
sock.on("error", function () { _dotEvict(key); });
|
|
360
|
+
sock.on("close", function () { if (_dotPool.get(key) === entry) _dotPool.delete(key); });
|
|
361
|
+
}
|
|
362
|
+
// Serialize: each socket handles one query at a time. If another
|
|
363
|
+
// query is in flight, queue behind it.
|
|
364
|
+
var waitTicket = entry._tail || Promise.resolve();
|
|
365
|
+
entry._tail = waitTicket.then(function () {
|
|
366
|
+
return new Promise(function (resolve, reject) {
|
|
367
|
+
entry.idle = false;
|
|
368
|
+
Promise.resolve(entry.ready).then(function () {
|
|
369
|
+
var lenBuf = Buffer.alloc(2);
|
|
370
|
+
lenBuf.writeUInt16BE(enc.buf.length, 0);
|
|
371
|
+
var got = [];
|
|
372
|
+
var expectLen = -1;
|
|
373
|
+
var done = false;
|
|
374
|
+
function settle(err, val) {
|
|
375
|
+
if (done) return;
|
|
376
|
+
done = true;
|
|
377
|
+
entry.sock.removeListener("data", onData);
|
|
378
|
+
entry.sock.removeListener("error", onErr);
|
|
379
|
+
entry.idle = true;
|
|
380
|
+
entry.lastUsedAt = Date.now();
|
|
381
|
+
if (err) reject(err); else resolve(val);
|
|
382
|
+
}
|
|
383
|
+
function onData(chunk) {
|
|
384
|
+
got.push(chunk);
|
|
385
|
+
var all = Buffer.concat(got);
|
|
386
|
+
if (expectLen === -1 && all.length >= 2) expectLen = all.readUInt16BE(0);
|
|
387
|
+
if (expectLen >= 0 && all.length >= expectLen + 2) {
|
|
388
|
+
try {
|
|
389
|
+
settle(null, _decodeDnsAnswer(all.slice(2, 2 + expectLen), qtype));
|
|
390
|
+
} catch (e) { settle(e); }
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function onErr(e) {
|
|
394
|
+
_dotEvict(key);
|
|
395
|
+
settle(new DnsError("dns/dot-failed", "DoT failed: " + e.message));
|
|
396
|
+
}
|
|
397
|
+
entry.sock.on("data", onData);
|
|
398
|
+
entry.sock.on("error", onErr);
|
|
399
|
+
entry.sock.write(lenBuf);
|
|
400
|
+
entry.sock.write(enc.buf);
|
|
401
|
+
});
|
|
313
402
|
});
|
|
314
|
-
sock.on("error", function (e) { reject(new DnsError("dns/dot-failed", "DoT failed: " + e.message)); });
|
|
315
403
|
});
|
|
404
|
+
return entry._tail;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function _resetDotPool() {
|
|
408
|
+
var keys = Array.from(_dotPool.keys());
|
|
409
|
+
for (var i = 0; i < keys.length; i++) _dotEvict(keys[i]);
|
|
316
410
|
}
|
|
317
411
|
|
|
318
412
|
function _orderAddrs(addrs) {
|
|
@@ -446,6 +540,7 @@ function _resetForTest() {
|
|
|
446
540
|
STATE.lookupTimeoutMs = 0; STATE.cacheTtlMs = 0; STATE.cacheNegativeTtlMs = 0;
|
|
447
541
|
STATE.doh = null; STATE.dot = null;
|
|
448
542
|
_clearCache();
|
|
543
|
+
_resetDotPool();
|
|
449
544
|
}
|
|
450
545
|
|
|
451
546
|
module.exports = {
|
package/lib/pagination.js
CHANGED
|
@@ -75,10 +75,17 @@
|
|
|
75
75
|
* module's offset() returns a `total` (from COUNT(*)) and computes
|
|
76
76
|
* `totalPages` so legacy clients can render numbered nav.
|
|
77
77
|
*
|
|
78
|
+
* Multi-column ordering:
|
|
79
|
+
* - orderBy accepts a string (single column), an array of strings
|
|
80
|
+
* (multiple columns, all using opts.direction), or an array of
|
|
81
|
+
* { column, direction } objects (mixed directions per column).
|
|
82
|
+
* The keyset WHERE expands to the standard OR cascade
|
|
83
|
+
* (col0 [op0] ? OR (col0 = ? AND col1 [op1] ?) OR ...)
|
|
84
|
+
* so successive pages can't repeat or skip rows when ties on the
|
|
85
|
+
* leading columns are broken by trailing ones. _id is appended as
|
|
86
|
+
* a tiebreaker if not already in the orderBy chain.
|
|
87
|
+
*
|
|
78
88
|
* Out of scope (with structural reasons documented):
|
|
79
|
-
* - Multi-column composite orderBy (orderBy: ["a", "b"]). Use raw
|
|
80
|
-
* SQL + encodeCursor / decodeCursor. The Query builder doesn't
|
|
81
|
-
* model multi-column ORDER BY today.
|
|
82
89
|
* - Cursor TTL / expiry. Operators who want time-limited cursors
|
|
83
90
|
* embed a timestamp in their own state and check at decode-time
|
|
84
91
|
* before passing to .cursor(). The framework's HMAC tag carries
|
|
@@ -202,6 +209,83 @@ function _resolveLimit(opts) {
|
|
|
202
209
|
|
|
203
210
|
// ---- Cursor pagination ----
|
|
204
211
|
|
|
212
|
+
// Normalize opts.orderBy into an array of { column, direction } entries.
|
|
213
|
+
// Accepts:
|
|
214
|
+
// undefined / null → [{ column: "_id", direction: opts.direction || "asc" }]
|
|
215
|
+
// "createdAt" → [{ column: "createdAt", direction: opts.direction || "asc" }]
|
|
216
|
+
// ["createdAt", "_id"] → all entries default to opts.direction || "asc"
|
|
217
|
+
// [{column:"a",direction:"desc"}, {column:"b"}]
|
|
218
|
+
// → mixed; missing direction defaults to opts.direction || "asc"
|
|
219
|
+
// Always appends an _id tiebreaker if not present, so cursor uniqueness
|
|
220
|
+
// is guaranteed regardless of the operator's spec.
|
|
221
|
+
function _normalizeOrderBy(opts) {
|
|
222
|
+
var defaultDir = (opts && opts.direction === "desc") ? "desc" : "asc";
|
|
223
|
+
var raw = opts && opts.orderBy;
|
|
224
|
+
var entries;
|
|
225
|
+
if (raw == null) {
|
|
226
|
+
entries = [{ column: "_id", direction: defaultDir }];
|
|
227
|
+
} else if (typeof raw === "string") {
|
|
228
|
+
entries = [{ column: raw, direction: defaultDir }];
|
|
229
|
+
} else if (Array.isArray(raw)) {
|
|
230
|
+
entries = raw.map(function (e) {
|
|
231
|
+
if (typeof e === "string") return { column: e, direction: defaultDir };
|
|
232
|
+
if (!e || typeof e !== "object" || typeof e.column !== "string") {
|
|
233
|
+
throw new PaginationError("pagination/bad-orderby",
|
|
234
|
+
"orderBy[] entries must be strings or { column, direction } objects, got " +
|
|
235
|
+
JSON.stringify(e));
|
|
236
|
+
}
|
|
237
|
+
var d = (e.direction || defaultDir).toLowerCase();
|
|
238
|
+
if (d !== "asc" && d !== "desc") {
|
|
239
|
+
throw new PaginationError("pagination/bad-orderby",
|
|
240
|
+
"orderBy[].direction must be 'asc' | 'desc', got " + JSON.stringify(e.direction));
|
|
241
|
+
}
|
|
242
|
+
return { column: e.column, direction: d };
|
|
243
|
+
});
|
|
244
|
+
} else {
|
|
245
|
+
throw new PaginationError("pagination/bad-orderby",
|
|
246
|
+
"orderBy must be a string, array, or omitted; got " + typeof raw);
|
|
247
|
+
}
|
|
248
|
+
for (var i = 0; i < entries.length; i++) {
|
|
249
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(entries[i].column)) {
|
|
250
|
+
throw new PaginationError("pagination/bad-orderby",
|
|
251
|
+
"orderBy column must match /^[A-Za-z_][A-Za-z0-9_]*$/ (identifier-safe), got " +
|
|
252
|
+
JSON.stringify(entries[i].column));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Append _id tiebreaker if not already in the chain. Direction
|
|
256
|
+
// matches the chain's last entry by convention so the tiebreaker
|
|
257
|
+
// doesn't reverse the natural reading order.
|
|
258
|
+
var hasId = entries.some(function (e) { return e.column === "_id"; });
|
|
259
|
+
if (!hasId) {
|
|
260
|
+
entries.push({ column: "_id", direction: entries[entries.length - 1].direction });
|
|
261
|
+
}
|
|
262
|
+
return entries;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Build the keyset WHERE clause for next-page navigation given the
|
|
266
|
+
// cursor's column values. Each (col, dir) entry expands the OR cascade:
|
|
267
|
+
// col0 [op0] ? OR (col0 = ? AND col1 [op1] ?) OR ... OR (col0 = ? AND ... AND coln [opn] ?)
|
|
268
|
+
// The compareOp per column flips by direction × forward (XNOR).
|
|
269
|
+
function _buildKeysetWhere(orderEntries, cursorVals, forward) {
|
|
270
|
+
var clauses = [];
|
|
271
|
+
var params = [];
|
|
272
|
+
for (var i = 0; i < orderEntries.length; i++) {
|
|
273
|
+
var entry = orderEntries[i];
|
|
274
|
+
// Effective direction: asc + forward → ">", desc + forward → "<", and reversed for backward.
|
|
275
|
+
var effectiveAsc = (entry.direction === "asc") === forward;
|
|
276
|
+
var op = effectiveAsc ? ">" : "<";
|
|
277
|
+
var equalChain = [];
|
|
278
|
+
for (var j = 0; j < i; j++) {
|
|
279
|
+
equalChain.push('"' + orderEntries[j].column + '" = ?');
|
|
280
|
+
params.push(cursorVals[j]);
|
|
281
|
+
}
|
|
282
|
+
equalChain.push('"' + entry.column + '" ' + op + ' ?');
|
|
283
|
+
params.push(cursorVals[i]);
|
|
284
|
+
clauses.push("(" + equalChain.join(" AND ") + ")");
|
|
285
|
+
}
|
|
286
|
+
return { sql: clauses.join(" OR "), params: params };
|
|
287
|
+
}
|
|
288
|
+
|
|
205
289
|
async function cursor(query, opts) {
|
|
206
290
|
if (!query || typeof query.where !== "function" || typeof query.orderBy !== "function" ||
|
|
207
291
|
typeof query.limit !== "function" || typeof query.all !== "function") {
|
|
@@ -213,110 +297,86 @@ async function cursor(query, opts) {
|
|
|
213
297
|
throw new PaginationError("pagination/no-secret",
|
|
214
298
|
"cursor: opts.secret is required (Buffer or non-empty string for HMAC tagging)");
|
|
215
299
|
}
|
|
216
|
-
var limit
|
|
217
|
-
var
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
// `req.query.orderBy` through doesn't create an SQL-injection vector.
|
|
222
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(orderBy)) {
|
|
223
|
-
throw new PaginationError("pagination/bad-orderby",
|
|
224
|
-
"cursor: orderBy must match /^[A-Za-z_][A-Za-z0-9_]*$/ (identifier-safe), got " +
|
|
225
|
-
JSON.stringify(orderBy));
|
|
226
|
-
}
|
|
227
|
-
var direction = (opts.direction === "desc") ? "desc" : "asc";
|
|
300
|
+
var limit = _resolveLimit(opts);
|
|
301
|
+
var orderEntries = _normalizeOrderBy(opts);
|
|
302
|
+
// Cursor compatibility key: array of `column:direction` pairs canonicalizes
|
|
303
|
+
// the orderBy spec so a stored cursor must match exactly to be replayed.
|
|
304
|
+
var orderKey = orderEntries.map(function (e) { return e.column + ":" + e.direction; });
|
|
228
305
|
|
|
229
|
-
// Decode incoming cursor (if any) and override direction from cursor.
|
|
230
|
-
// The cursor authoritatively encodes which way we're paging — operators
|
|
231
|
-
// shouldn't need to round-trip direction in the URL.
|
|
232
306
|
var cursorState = null;
|
|
233
307
|
var forward = (opts.forward !== false);
|
|
234
308
|
if (opts.cursor) {
|
|
235
309
|
cursorState = decodeCursor(opts.cursor, opts.secret);
|
|
236
|
-
|
|
310
|
+
var cursorKey = Array.isArray(cursorState.orderKey) ? cursorState.orderKey :
|
|
311
|
+
// Back-compat with v0.6.20 single-column cursors: synthesize the
|
|
312
|
+
// key from the legacy orderBy/dir fields.
|
|
313
|
+
[cursorState.orderBy + ":" + cursorState.dir];
|
|
314
|
+
if (JSON.stringify(cursorKey) !== JSON.stringify(orderKey)) {
|
|
237
315
|
throw new PaginationError("pagination/cursor-mismatch",
|
|
238
|
-
"cursor
|
|
239
|
-
|
|
240
|
-
direction + "' — operator must use the same opts the cursor was issued under");
|
|
316
|
+
"cursor orderKey [" + cursorKey.join(", ") + "] does not match call orderKey [" +
|
|
317
|
+
orderKey.join(", ") + "] — operator must use the same orderBy/direction spec");
|
|
241
318
|
}
|
|
242
319
|
if (typeof cursorState.forward === "boolean") forward = cursorState.forward;
|
|
243
320
|
}
|
|
244
321
|
|
|
245
|
-
// Apply the
|
|
246
|
-
// asc + forward → strictly greater than (orderByVal, _id)
|
|
247
|
-
// asc + backward → strictly less than (orderByVal, _id)
|
|
248
|
-
// desc + forward → strictly less than
|
|
249
|
-
// desc + backward → strictly greater than
|
|
250
|
-
// We always SELECT in the direction that matches forward (so the
|
|
251
|
-
// result rows arrive in the right reading order), then if
|
|
252
|
-
// backward we reverse client-side at the end.
|
|
253
|
-
var effectiveAsc = (direction === "asc") === forward; // XNOR
|
|
254
|
-
var compareOp;
|
|
322
|
+
// Apply the keyset WHERE if a cursor is present.
|
|
255
323
|
if (cursorState) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
324
|
+
var cursorVals;
|
|
325
|
+
if (Array.isArray(cursorState.vals)) {
|
|
326
|
+
cursorVals = cursorState.vals;
|
|
327
|
+
} else {
|
|
328
|
+
// v0.6.20 single-column shape — synthesize the vals array.
|
|
329
|
+
cursorVals = [cursorState.orderByVal, cursorState.id];
|
|
330
|
+
// Drop the synthetic _id append if the legacy cursor already had it
|
|
331
|
+
if (orderEntries.length === 1) cursorVals = [cursorState.orderByVal];
|
|
332
|
+
}
|
|
333
|
+
if (cursorVals.length !== orderEntries.length) {
|
|
334
|
+
throw new PaginationError("pagination/cursor-mismatch",
|
|
335
|
+
"cursor encoded " + cursorVals.length + " column value(s) but orderBy has " +
|
|
336
|
+
orderEntries.length + " — operator changed the orderBy spec mid-flight");
|
|
337
|
+
}
|
|
338
|
+
var where = _buildKeysetWhere(orderEntries, cursorVals, forward);
|
|
339
|
+
query.whereRaw(where.sql, where.params);
|
|
264
340
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
// No raw orderBy needed because the WHERE condition above
|
|
274
|
-
// strictly disambiguates (orderByVal, _id) tuples — successive
|
|
275
|
-
// pages can't repeat or skip a row even with ties on orderBy.
|
|
341
|
+
|
|
342
|
+
// Apply ORDER BY for each entry. When forward=false we reverse direction
|
|
343
|
+
// per entry so the SQL returns rows in the right reading order; we then
|
|
344
|
+
// reverse client-side at the end.
|
|
345
|
+
for (var oi = 0; oi < orderEntries.length; oi++) {
|
|
346
|
+
var entry = orderEntries[oi];
|
|
347
|
+
var effectiveDir = ((entry.direction === "asc") === forward) ? "asc" : "desc";
|
|
348
|
+
query.orderBy(entry.column, effectiveDir);
|
|
276
349
|
}
|
|
277
350
|
query.limit(limit + 1);
|
|
278
351
|
|
|
279
352
|
var rows = await Promise.resolve(query.all());
|
|
280
353
|
|
|
281
|
-
// Tiebreaker stability: when orderBy != _id, the SQL only sorts by
|
|
282
|
-
// orderBy. Within an orderByVal cluster, sort by _id in JS so the
|
|
283
|
-
// cursor predicate's _id-based tiebreaker stays consistent with the
|
|
284
|
-
// returned ordering.
|
|
285
|
-
if (orderBy !== "_id") {
|
|
286
|
-
rows.sort(function (a, b) {
|
|
287
|
-
var av = a[orderBy], bv = b[orderBy];
|
|
288
|
-
if (av < bv) return effectiveAsc ? -1 : 1;
|
|
289
|
-
if (av > bv) return effectiveAsc ? 1 : -1;
|
|
290
|
-
var ai = String(a._id), bi = String(b._id);
|
|
291
|
-
if (ai < bi) return effectiveAsc ? -1 : 1;
|
|
292
|
-
if (ai > bi) return effectiveAsc ? 1 : -1;
|
|
293
|
-
return 0;
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
|
|
297
354
|
var hasMore = rows.length > limit;
|
|
298
355
|
var page = hasMore ? rows.slice(0, limit) : rows.slice();
|
|
299
356
|
if (!forward) page.reverse();
|
|
300
357
|
|
|
358
|
+
function _valsForRow(row) {
|
|
359
|
+
return orderEntries.map(function (e) {
|
|
360
|
+
return e.column === "_id" ? String(row._id) : row[e.column];
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
301
364
|
var nextCursor = null;
|
|
302
365
|
var prevCursor = null;
|
|
303
366
|
if (hasMore && page.length > 0) {
|
|
304
367
|
var last = page[page.length - 1];
|
|
305
368
|
nextCursor = encodeCursor({
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
forward:
|
|
369
|
+
orderKey: orderKey,
|
|
370
|
+
vals: _valsForRow(last),
|
|
371
|
+
forward: true,
|
|
309
372
|
}, opts.secret);
|
|
310
373
|
}
|
|
311
|
-
// Always emit a prev cursor when we have a starting position (the
|
|
312
|
-
// operator was on a non-first page). Operator UI hides it on the
|
|
313
|
-
// first page.
|
|
314
374
|
if (cursorState && page.length > 0) {
|
|
315
375
|
var first = page[0];
|
|
316
376
|
prevCursor = encodeCursor({
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
forward:
|
|
377
|
+
orderKey: orderKey,
|
|
378
|
+
vals: _valsForRow(first),
|
|
379
|
+
forward: false,
|
|
320
380
|
}, opts.secret);
|
|
321
381
|
}
|
|
322
382
|
|
package/lib/parsers/index.js
CHANGED
|
@@ -42,19 +42,33 @@
|
|
|
42
42
|
* opted in. Dev-tooling — production secrets should still
|
|
43
43
|
* come through the operator's secrets-management; this is
|
|
44
44
|
* the local-development convenience.
|
|
45
|
-
* ini —
|
|
45
|
+
* ini — INI / .gitconfig / systemd-unit / php.ini / tox.ini parser.
|
|
46
|
+
* Sections (incl. [parent.child] / [parent "child"] nesting),
|
|
47
|
+
* ; or # comments (inline + leading), single + double quoting
|
|
48
|
+
* with \n / \t / \\ / \" / \' escapes, boolean coercion
|
|
49
|
+
* (true/false/yes/no/on/off), decimal + hex integers + floats.
|
|
50
|
+
* Prototype-pollution defense (__proto__/constructor/prototype
|
|
51
|
+
* rejected); duplicate-key policy throws by default
|
|
52
|
+
* (onDuplicate: "first"|"last" opts in to silent
|
|
53
|
+
* shadowing); section + per-section key + value-bytes
|
|
54
|
+
* caps configurable via opts.
|
|
46
55
|
*
|
|
47
56
|
* Public API:
|
|
48
57
|
* parsers.xml.parse(input, opts?) → object
|
|
58
|
+
* parsers.ini.parse(input, opts?) → object
|
|
59
|
+
* parsers.toml.parse(input, opts?) → object
|
|
60
|
+
* parsers.yaml.parse(input, opts?) → object
|
|
61
|
+
* parsers.env.load(filepath, opts?) → { values, diff }
|
|
49
62
|
*
|
|
50
63
|
* (CSV moved to top-level `b.csv` in v0.5.17 — same surface unified.)
|
|
51
64
|
*
|
|
52
65
|
* Error types: each parser exports its own *SafeError class with .code
|
|
53
|
-
* matching the format (xml/..., toml/...).
|
|
66
|
+
* matching the format (xml/..., toml/..., ini/..., yaml/..., env/...).
|
|
54
67
|
*/
|
|
55
68
|
module.exports = {
|
|
56
69
|
xml: require("./safe-xml"),
|
|
57
70
|
toml: require("./safe-toml"),
|
|
58
71
|
yaml: require("./safe-yaml"),
|
|
59
72
|
env: require("./safe-env"),
|
|
73
|
+
ini: require("./safe-ini"),
|
|
60
74
|
};
|