@blamejs/core 0.6.20 → 0.6.21

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.
@@ -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 we get for free vs. what's
102
- // out of reach today:
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. We'll plumb it through when h3 lands.
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 + Content-ID
407
- // is interpreted correctly by every major client even inside mixed;
408
- // operators with strict-RFC-2387 multipart/related needs subscribe
409
- // to a future patch when demand surfaces.
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 = [];
@@ -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
- STATE.doh = { url: url };
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 url = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
252
- var u = new URL(url);
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 req = https.request({
255
- hostname: u.hostname,
256
- port: u.port || 443,
257
- path: u.pathname + u.search,
258
- method: "GET",
259
- headers: { "accept": "application/dns-message" },
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: C.TLS_GROUP_CURVE_STR,
262
- }, function (res) {
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
- return new Promise(function (resolve, reject) {
285
- var sock = tls.connect({
286
- host: STATE.dot.host,
287
- port: STATE.dot.port,
288
- servername: STATE.dot.servername,
289
- minVersion: "TLSv1.3",
290
- ecdhCurve: C.TLS_GROUP_CURVE_STR,
291
- });
292
- var lenBuf = Buffer.alloc(2);
293
- lenBuf.writeUInt16BE(enc.buf.length, 0);
294
- var got = [];
295
- var expectLen = -1;
296
- sock.on("secureConnect", function () {
297
- sock.write(lenBuf);
298
- sock.write(enc.buf);
299
- });
300
- sock.on("data", function (chunk) {
301
- got.push(chunk);
302
- var all = Buffer.concat(got);
303
- if (expectLen === -1 && all.length >= 2) {
304
- expectLen = all.readUInt16BE(0);
305
- }
306
- if (expectLen >= 0 && all.length >= expectLen + 2) {
307
- try {
308
- var ans = _decodeDnsAnswer(all.slice(2, 2 + expectLen), qtype);
309
- sock.destroy();
310
- resolve(ans);
311
- } catch (e) { sock.destroy(); reject(e); }
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 = _resolveLimit(opts);
217
- var orderBy = typeof opts.orderBy === "string" && opts.orderBy.length > 0 ? opts.orderBy : "_id";
218
- // Throw at call site on bad orderBy — the value is interpolated into
219
- // a raw SQL fragment for the keyset where-clause. Restrict to
220
- // identifier-safe characters so a careless caller piping
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
- if (cursorState.orderBy !== orderBy || cursorState.dir !== direction) {
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 was created with orderBy='" + cursorState.orderBy + "' direction='" +
239
- cursorState.dir + "' but call uses orderBy='" + orderBy + "' direction='" +
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 cursor predicate. Direction interacts with forward/backward:
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
- compareOp = effectiveAsc ? ">" : "<";
257
- // (orderByVal, _id) <op> (?, ?)
258
- // Express via OR to be portable across SQLite + Postgres.
259
- var oCol = orderBy;
260
- query.whereRaw(
261
- '"' + oCol + '" ' + compareOp + ' ? OR ("' + oCol + '" = ? AND "_id" ' + compareOp + ' ?)',
262
- [cursorState.orderByVal, cursorState.orderByVal, cursorState.id]
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
- query.orderBy(orderBy, effectiveAsc ? "asc" : "desc");
266
- if (orderBy !== "_id") {
267
- // Tiebreaker by _id in the same direction the framework Query
268
- // only models a single orderBy, so we add the tiebreaker as a
269
- // raw ORDER BY suffix via _orderLimitOffset cooperation. Today
270
- // Query lacks multi-orderBy; we emulate by sorting in-memory
271
- // after the fetch using _id within each orderBy group. Keeps
272
- // pagination correct without expanding the Query API.
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
- dir: direction, orderBy: orderBy,
307
- orderByVal: last[orderBy], id: String(last._id),
308
- forward: true,
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
- dir: direction, orderBy: orderBy,
318
- orderByVal: first[orderBy], id: String(first._id),
319
- forward: false,
377
+ orderKey: orderKey,
378
+ vals: _valsForRow(first),
379
+ forward: false,
320
380
  }, opts.secret);
321
381
  }
322
382
 
@@ -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 — Windows .ini files (rare today; lower priority)
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
  };