flongo 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/dist/cache/cacheKeyGenerator.js +10 -0
- package/dist/flongoCollection.d.ts +29 -0
- package/dist/flongoCollection.js +92 -0
- package/dist/flongoQuery.d.ts +114 -6
- package/dist/flongoQuery.js +177 -8
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,66 @@ const results = await collection.getAll(
|
|
|
105
105
|
query.orderBy('createdAt', SortDirection.Descending),
|
|
106
106
|
{ offset: 0, count: 20 }
|
|
107
107
|
);
|
|
108
|
+
|
|
109
|
+
// Compound (multi-field) sort with a unique tiebreaker.
|
|
110
|
+
// Appending `_id` makes skip/limit pagination deterministic, so pages never
|
|
111
|
+
// overlap or skip documents when the primary sort key has ties.
|
|
112
|
+
const page = await collection.getAll(
|
|
113
|
+
new FlongoQuery()
|
|
114
|
+
.where('status').eq('accepted')
|
|
115
|
+
.orderBy('featured', SortDirection.Descending)
|
|
116
|
+
.thenBy('_id', SortDirection.Ascending), // => sort: { featured: -1, _id: 1 }
|
|
117
|
+
{ offset: 0, count: 24 }
|
|
118
|
+
);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
#### Seeded random sort (`orderByRandom`)
|
|
122
|
+
|
|
123
|
+
`orderByRandom(seed)` adds a **deterministic, seeded shuffle** for fair, rotating
|
|
124
|
+
list/browse orderings (e.g. a public directory with no meaningful ranking yet).
|
|
125
|
+
Given the same seed it produces the **same order on every call**, so `$skip`/`$limit`
|
|
126
|
+
pagination stays gapless and non-overlapping across pages; change the seed and the
|
|
127
|
+
whole set reshuffles.
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
131
|
+
// Caller owns the rotation policy. Here: a new shuffle each day. Pin the seed for
|
|
132
|
+
// a browsing session (capture on first load, echo back per page) so paging is stable
|
|
133
|
+
// and rotation only affects new sessions.
|
|
134
|
+
const seed = sessionSeed ?? Math.floor(Date.now() / DAY_MS);
|
|
135
|
+
|
|
136
|
+
const query = new FlongoQuery()
|
|
137
|
+
.where('enable').eq(true)
|
|
138
|
+
.orderBy('featured', SortDirection.Descending) // pinned groups stay on top
|
|
139
|
+
.orderByRandom(seed); // shuffle within each featured tier
|
|
140
|
+
|
|
141
|
+
const page = await stays.getAll(query, { offset: 0, count: 24 }); // stable, fair
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- **Composable** with `orderBy`/`thenBy`: sort keys apply in call order and the
|
|
145
|
+
shuffle slots in at *its* call position. `_id` is always appended as a final
|
|
146
|
+
tiebreaker, so pages are gapless even under hash collisions.
|
|
147
|
+
- **Seed is caller-owned** — Flongo does not decide rotation cadence. Pass a
|
|
148
|
+
time-bucketed seed for rotation, optionally mixed with a session/user id. Number
|
|
149
|
+
and string seeds are normalized to a string, so `1` and `'1'` are equivalent.
|
|
150
|
+
- **Execution**: a query carrying `orderByRandom` runs via an aggregation pipeline
|
|
151
|
+
(using `$toHashedIndexKey`) instead of `find` — the `getAll`/`getSome` signatures
|
|
152
|
+
and return types are unchanged. **Requires MongoDB server ≥ 8.0**; on older
|
|
153
|
+
servers `orderByRandom` fails fast with an actionable error.
|
|
154
|
+
|
|
155
|
+
#### Raw aggregation escape hatch
|
|
156
|
+
|
|
157
|
+
For computed-field sorts/rankings the fluent builder doesn't cover yet,
|
|
158
|
+
`FlongoCollection.aggregate(pipeline)` runs a raw MongoDB aggregation pipeline and
|
|
159
|
+
returns documents in Entity form (`_id` normalized to a string):
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
const topRated = await products.aggregate([
|
|
163
|
+
{ $match: { inStock: true } },
|
|
164
|
+
{ $addFields: { score: { $multiply: ['$rating', '$reviewCount'] } } },
|
|
165
|
+
{ $sort: { score: -1 } },
|
|
166
|
+
{ $limit: 10 }
|
|
167
|
+
]);
|
|
108
168
|
```
|
|
109
169
|
|
|
110
170
|
### Collection Operations
|
|
@@ -72,6 +72,16 @@ class CacheKeyGenerator {
|
|
|
72
72
|
direction: query.orderDirection || types_1.SortDirection.Ascending
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
+
// Include the full ordered sort list only when tiebreakers are present, so
|
|
76
|
+
// queries differing only by a tiebreaker (e.g. `{ featured: -1 }` vs
|
|
77
|
+
// `{ featured: -1, _id: 1 }`) hash to distinct keys. Single-sort queries
|
|
78
|
+
// keep their previous key shape (just `order`) for cache continuity.
|
|
79
|
+
if (query.sorts && query.sorts.length > 1) {
|
|
80
|
+
normalized.sorts = query.sorts.map(sort => ({
|
|
81
|
+
field: sort.field,
|
|
82
|
+
direction: sort.direction || types_1.SortDirection.Ascending
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
75
85
|
if (query.orQueries && query.orQueries.length > 0) {
|
|
76
86
|
normalized.or = query.orQueries
|
|
77
87
|
.map(q => this.normalizeQuery(q))
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { FlongoQuery } from "./flongoQuery";
|
|
2
2
|
import { Entity, Event, Pagination, Repository } from "./types";
|
|
3
|
+
import { Document } from "mongodb";
|
|
4
|
+
/**
|
|
5
|
+
* Clears the memoized per-connection server-version check. Exported for tests;
|
|
6
|
+
* production code relies on the connection-keyed cache invalidating itself.
|
|
7
|
+
*/
|
|
8
|
+
export declare function __resetServerVersionCache(): void;
|
|
3
9
|
/**
|
|
4
10
|
* Configuration options for FlongoCollection instances
|
|
5
11
|
*/
|
|
@@ -76,6 +82,29 @@ export declare class FlongoCollection<T> {
|
|
|
76
82
|
* @returns Promise resolving to array of documents
|
|
77
83
|
*/
|
|
78
84
|
getAll(query?: FlongoQuery, pagination?: Pagination): Promise<(Entity & T)[]>;
|
|
85
|
+
/**
|
|
86
|
+
* Executes a query carrying an `orderByRandom` via an aggregation pipeline.
|
|
87
|
+
* Verifies the server supports `$toHashedIndexKey` (MongoDB >= 8.0) first so
|
|
88
|
+
* callers get an actionable error rather than a raw driver failure.
|
|
89
|
+
* @private
|
|
90
|
+
*/
|
|
91
|
+
private getAllRandom;
|
|
92
|
+
/**
|
|
93
|
+
* Runs a raw aggregation pipeline against this collection and returns the
|
|
94
|
+
* documents with their `_id` normalized to a string (Entity form). A low-level
|
|
95
|
+
* escape hatch for computed-field sorts/rankings that the fluent builder does
|
|
96
|
+
* not yet cover; `orderByRandom` is built on the same execution path.
|
|
97
|
+
* @param pipeline - MongoDB aggregation pipeline stages
|
|
98
|
+
* @returns Promise resolving to the aggregated documents in Entity form
|
|
99
|
+
*/
|
|
100
|
+
aggregate(pipeline: Document[]): Promise<(Entity & T)[]>;
|
|
101
|
+
/**
|
|
102
|
+
* Throws a clear, actionable error when the connected MongoDB server predates
|
|
103
|
+
* the `$toHashedIndexKey` operator that `orderByRandom` relies on (added in
|
|
104
|
+
* 8.0), instead of letting an unrecognized-operator driver error surface.
|
|
105
|
+
* @private
|
|
106
|
+
*/
|
|
107
|
+
private assertRandomSortSupported;
|
|
79
108
|
/**
|
|
80
109
|
* Retrieves a subset of documents with required query and pagination
|
|
81
110
|
* Similar to getAll but requires both query and pagination parameters
|
package/dist/flongoCollection.js
CHANGED
|
@@ -1,11 +1,51 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.FlongoCollection = void 0;
|
|
4
|
+
exports.__resetServerVersionCache = __resetServerVersionCache;
|
|
4
5
|
const flongo_1 = require("./flongo");
|
|
5
6
|
const flongoQuery_1 = require("./flongoQuery");
|
|
6
7
|
const types_1 = require("./types");
|
|
7
8
|
const mongodb_1 = require("mongodb");
|
|
8
9
|
const errors_1 = require("./errors");
|
|
10
|
+
/**
|
|
11
|
+
* Memoized MongoDB major version for the active `flongoDb` connection. Keyed by
|
|
12
|
+
* the `flongoDb` reference so a reconnect (which replaces `flongoDb`) misses the
|
|
13
|
+
* cache and transparently re-checks; shared across all FlongoCollection
|
|
14
|
+
* instances on the same connection so `orderByRandom` costs at most one
|
|
15
|
+
* `buildInfo` round-trip per connection.
|
|
16
|
+
*/
|
|
17
|
+
let cachedServerMajor;
|
|
18
|
+
/**
|
|
19
|
+
* Resolves the connected server's major version, memoized per connection.
|
|
20
|
+
* Concurrent callers share a single in-flight `buildInfo`; a failed check is not
|
|
21
|
+
* cached so the next call retries.
|
|
22
|
+
*/
|
|
23
|
+
function getServerMajorVersion() {
|
|
24
|
+
if (cachedServerMajor && cachedServerMajor.db === flongo_1.flongoDb) {
|
|
25
|
+
return cachedServerMajor.promise;
|
|
26
|
+
}
|
|
27
|
+
const promise = (async () => {
|
|
28
|
+
const info = await flongo_1.flongoDb.command({ buildInfo: 1 });
|
|
29
|
+
return Array.isArray(info.versionArray)
|
|
30
|
+
? Number(info.versionArray[0])
|
|
31
|
+
: parseInt(String(info.version), 10);
|
|
32
|
+
})();
|
|
33
|
+
// Don't cache a transient failure — allow the next call to retry.
|
|
34
|
+
promise.catch(() => {
|
|
35
|
+
if (cachedServerMajor?.promise === promise) {
|
|
36
|
+
cachedServerMajor = undefined;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
cachedServerMajor = { db: flongo_1.flongoDb, promise };
|
|
40
|
+
return promise;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Clears the memoized per-connection server-version check. Exported for tests;
|
|
44
|
+
* production code relies on the connection-keyed cache invalidating itself.
|
|
45
|
+
*/
|
|
46
|
+
function __resetServerVersionCache() {
|
|
47
|
+
cachedServerMajor = undefined;
|
|
48
|
+
}
|
|
9
49
|
/**
|
|
10
50
|
* FlongoCollection provides a high-level interface for MongoDB collection operations
|
|
11
51
|
* with automatic entity management, event logging, and fluent query support.
|
|
@@ -88,6 +128,11 @@ class FlongoCollection {
|
|
|
88
128
|
* @returns Promise resolving to array of documents
|
|
89
129
|
*/
|
|
90
130
|
async getAll(query, pagination) {
|
|
131
|
+
// A seeded random sort can't be expressed with find().sort() — it sorts by a
|
|
132
|
+
// computed per-document value, so it routes through an aggregation pipeline.
|
|
133
|
+
if (query?.hasRandomSort()) {
|
|
134
|
+
return this.getAllRandom(query, pagination);
|
|
135
|
+
}
|
|
91
136
|
const mongodbQuery = query?.build() ?? {};
|
|
92
137
|
const mongodbOptions = query?.buildOptions(pagination) ?? new flongoQuery_1.FlongoQuery().buildOptions(pagination);
|
|
93
138
|
try {
|
|
@@ -98,6 +143,49 @@ class FlongoCollection {
|
|
|
98
143
|
throw err;
|
|
99
144
|
}
|
|
100
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Executes a query carrying an `orderByRandom` via an aggregation pipeline.
|
|
148
|
+
* Verifies the server supports `$toHashedIndexKey` (MongoDB >= 8.0) first so
|
|
149
|
+
* callers get an actionable error rather than a raw driver failure.
|
|
150
|
+
* @private
|
|
151
|
+
*/
|
|
152
|
+
async getAllRandom(query, pagination) {
|
|
153
|
+
await this.assertRandomSortSupported();
|
|
154
|
+
const pipeline = query.buildPipeline(pagination);
|
|
155
|
+
const res = await this.collection.aggregate(pipeline).toArray();
|
|
156
|
+
return res.map((d) => this.toEntity(d));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Runs a raw aggregation pipeline against this collection and returns the
|
|
160
|
+
* documents with their `_id` normalized to a string (Entity form). A low-level
|
|
161
|
+
* escape hatch for computed-field sorts/rankings that the fluent builder does
|
|
162
|
+
* not yet cover; `orderByRandom` is built on the same execution path.
|
|
163
|
+
* @param pipeline - MongoDB aggregation pipeline stages
|
|
164
|
+
* @returns Promise resolving to the aggregated documents in Entity form
|
|
165
|
+
*/
|
|
166
|
+
async aggregate(pipeline) {
|
|
167
|
+
const res = await this.collection.aggregate(pipeline).toArray();
|
|
168
|
+
return res.map((d) => this.toEntity(d));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Throws a clear, actionable error when the connected MongoDB server predates
|
|
172
|
+
* the `$toHashedIndexKey` operator that `orderByRandom` relies on (added in
|
|
173
|
+
* 8.0), instead of letting an unrecognized-operator driver error surface.
|
|
174
|
+
* @private
|
|
175
|
+
*/
|
|
176
|
+
async assertRandomSortSupported() {
|
|
177
|
+
let major;
|
|
178
|
+
try {
|
|
179
|
+
major = await getServerMajorVersion();
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
console.error("Failed to verify MongoDB server version for orderByRandom: ", err);
|
|
183
|
+
throw new errors_1.Error400("orderByRandom() could not verify the MongoDB server version. It requires MongoDB server >= 8.0 (uses $toHashedIndexKey).");
|
|
184
|
+
}
|
|
185
|
+
if (!(major >= 8)) {
|
|
186
|
+
throw new errors_1.Error400(`orderByRandom() requires MongoDB server >= 8.0 (uses $toHashedIndexKey), but the connected server is ${major}.x.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
101
189
|
/**
|
|
102
190
|
* Retrieves a subset of documents with required query and pagination
|
|
103
191
|
* Similar to getAll but requires both query and pagination parameters
|
|
@@ -106,6 +194,10 @@ class FlongoCollection {
|
|
|
106
194
|
* @returns Promise resolving to array of documents
|
|
107
195
|
*/
|
|
108
196
|
async getSome(query, pagination) {
|
|
197
|
+
// Mirror getAll: a seeded random sort routes through the aggregation path.
|
|
198
|
+
if (query?.hasRandomSort()) {
|
|
199
|
+
return this.getAllRandom(query, pagination);
|
|
200
|
+
}
|
|
109
201
|
const mongodbQuery = query?.build();
|
|
110
202
|
const mongodbOptions = query?.buildOptions(pagination);
|
|
111
203
|
return (await this.collection.find(mongodbQuery, mongodbOptions).toArray()).map((d) => this.toEntity(d));
|
package/dist/flongoQuery.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Bounds, Coordinates, Pagination, ColExpression, ColRange, ICollectionQuery, Logic, SortDirection } from "./types";
|
|
1
|
+
import { Bounds, Coordinates, Pagination, ColExpression, ColRange, ICollectionQuery, Logic, Sort, SortDirection } from "./types";
|
|
2
2
|
import { Document, Filter, FindOptions } from "mongodb";
|
|
3
3
|
/**
|
|
4
4
|
* FlongoQuery provides a fluent, chainable interface for building MongoDB queries
|
|
@@ -19,10 +19,37 @@ export declare class FlongoQuery implements ICollectionQuery {
|
|
|
19
19
|
expressions: ColExpression[];
|
|
20
20
|
/** Array of range queries (currently unused but reserved for future features) */
|
|
21
21
|
ranges: ColRange[];
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Ordered list of sort keys. The first entry is the primary sort, subsequent
|
|
24
|
+
* entries act as tiebreakers (in order). Populated by `orderBy()`/`thenBy()`.
|
|
25
|
+
*/
|
|
26
|
+
sorts: Sort[];
|
|
27
|
+
/**
|
|
28
|
+
* Normalized seed for a deterministic random sort, set by `orderByRandom()`.
|
|
29
|
+
* `undefined` means the query has no random sort and executes via the normal
|
|
30
|
+
* `find` path. When present, the query must execute via an aggregation
|
|
31
|
+
* pipeline (see `buildPipeline`). Requires MongoDB server >= 8.0.
|
|
32
|
+
*/
|
|
33
|
+
randomSeed?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The position of the random sort within the sort-key order, captured as
|
|
36
|
+
* `sorts.length` at the time `orderByRandom()` was called. Explicit sort keys
|
|
37
|
+
* added before this index sort ahead of the shuffle; those added at/after it
|
|
38
|
+
* sort behind it. `undefined` when there is no random sort.
|
|
39
|
+
*/
|
|
40
|
+
randomSortPosition?: number;
|
|
41
|
+
/** Internal computed field used to carry the per-document shuffle hash. */
|
|
42
|
+
static readonly SHUFFLE_FIELD = "__flongoShuffle";
|
|
43
|
+
/**
|
|
44
|
+
* Primary sort field, derived from `sorts[0]`.
|
|
45
|
+
* @deprecated Retained for backward compatibility; use `sorts`.
|
|
46
|
+
*/
|
|
47
|
+
get orderField(): string | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Primary sort direction, derived from `sorts[0]`.
|
|
50
|
+
* @deprecated Retained for backward compatibility; use `sorts`.
|
|
51
|
+
*/
|
|
52
|
+
get orderDirection(): SortDirection | undefined;
|
|
26
53
|
/** Array of queries to be combined with OR logic */
|
|
27
54
|
orQueries: FlongoQuery[];
|
|
28
55
|
/** Array of queries to be combined with AND logic */
|
|
@@ -224,12 +251,71 @@ export declare class FlongoQuery implements ICollectionQuery {
|
|
|
224
251
|
*/
|
|
225
252
|
near(center: Coordinates, maxDistanceMeters: number): FlongoQuery;
|
|
226
253
|
/**
|
|
227
|
-
* Sets the field and direction
|
|
254
|
+
* Sets the primary sort field and direction, resetting any previously
|
|
255
|
+
* configured sort keys (including tiebreakers added via `thenBy`).
|
|
228
256
|
* @param field - Field name to sort by
|
|
229
257
|
* @param direction - Sort direction (ascending or descending)
|
|
230
258
|
* @returns This query instance for chaining
|
|
231
259
|
*/
|
|
232
260
|
orderBy(field?: string, direction?: SortDirection): FlongoQuery;
|
|
261
|
+
/**
|
|
262
|
+
* Appends a secondary (tertiary, …) sort key used as a tiebreaker after the
|
|
263
|
+
* primary `orderBy` field. Sort keys are applied in the order they are added,
|
|
264
|
+
* which is what makes deterministic pagination possible (e.g. appending a
|
|
265
|
+
* unique `_id` tiebreaker so `skip`/`limit` pages don't overlap).
|
|
266
|
+
*
|
|
267
|
+
* If called before any `orderBy`, the field becomes the primary sort.
|
|
268
|
+
* If the same field is added more than once, the latest direction wins
|
|
269
|
+
* (the field keeps its original position in the sort order).
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* new FlongoQuery()
|
|
273
|
+
* .orderBy('featured', SortDirection.Descending)
|
|
274
|
+
* .thenBy('_id', SortDirection.Ascending);
|
|
275
|
+
* // => sort: { featured: -1, _id: 1 }
|
|
276
|
+
*
|
|
277
|
+
* @param field - Field name to sort by
|
|
278
|
+
* @param direction - Sort direction (ascending or descending)
|
|
279
|
+
* @returns This query instance for chaining
|
|
280
|
+
*/
|
|
281
|
+
thenBy(field?: string, direction?: SortDirection): FlongoQuery;
|
|
282
|
+
/**
|
|
283
|
+
* Adds a deterministic, seeded random sort. Given the same seed the produced
|
|
284
|
+
* order is identical on every call, so `$skip`/`$limit` pagination stays
|
|
285
|
+
* gapless and non-overlapping across pages; change the seed and the whole set
|
|
286
|
+
* reshuffles. This is what enables fair, rotating list/browse orderings.
|
|
287
|
+
*
|
|
288
|
+
* Composes with `orderBy`/`thenBy`: sort keys apply in call order and the
|
|
289
|
+
* shuffle slots in at the position of *this* call, so
|
|
290
|
+
* `orderBy('featured', Descending).orderByRandom(seed)` pins featured docs to
|
|
291
|
+
* the top and shuffles within each tier. `_id` is always appended as a final
|
|
292
|
+
* tiebreaker so a total order (and therefore stable paging) is guaranteed even
|
|
293
|
+
* under hash collisions. Calling this alone shuffles the whole result set.
|
|
294
|
+
*
|
|
295
|
+
* The seed is caller-owned — Flongo does not decide rotation policy. Pass e.g.
|
|
296
|
+
* `Math.floor(Date.now() / DAY_MS)` for daily rotation, optionally mixed with a
|
|
297
|
+
* session/user id. Number and string seeds are normalized to a string, so `1`
|
|
298
|
+
* and `'1'` produce the same order.
|
|
299
|
+
*
|
|
300
|
+
* Implemented natively via the `$toHashedIndexKey` aggregation operator, so a
|
|
301
|
+
* query carrying a random sort executes via an aggregation pipeline instead of
|
|
302
|
+
* `find`. **Requires MongoDB server >= 8.0.**
|
|
303
|
+
*
|
|
304
|
+
* @example
|
|
305
|
+
* new FlongoQuery()
|
|
306
|
+
* .where('enable').eq(true)
|
|
307
|
+
* .orderBy('featured', SortDirection.Descending)
|
|
308
|
+
* .orderByRandom(seed);
|
|
309
|
+
*
|
|
310
|
+
* @param seed - Seed controlling the shuffle (number or string)
|
|
311
|
+
* @returns This query instance for chaining
|
|
312
|
+
*/
|
|
313
|
+
orderByRandom(seed: number | string): FlongoQuery;
|
|
314
|
+
/**
|
|
315
|
+
* Whether this query carries a random sort and must execute via an aggregation
|
|
316
|
+
* pipeline (`buildPipeline`) rather than the normal `find` path.
|
|
317
|
+
*/
|
|
318
|
+
hasRandomSort(): boolean;
|
|
233
319
|
/**
|
|
234
320
|
* Adds a sub-query with AND logic
|
|
235
321
|
* @param query - Sub-query to combine with AND logic
|
|
@@ -254,6 +340,28 @@ export declare class FlongoQuery implements ICollectionQuery {
|
|
|
254
340
|
* @returns MongoDB FindOptions object
|
|
255
341
|
*/
|
|
256
342
|
buildOptions<T extends Document>(pagination?: Pagination): FindOptions<T>;
|
|
343
|
+
/**
|
|
344
|
+
* Builds the ordered `$sort` specification for the aggregation path, weaving
|
|
345
|
+
* the internal shuffle field in at its call position amongst the explicit
|
|
346
|
+
* sort keys and always appending `_id` as a final tiebreaker. Only used when
|
|
347
|
+
* a random sort is present.
|
|
348
|
+
* @private
|
|
349
|
+
*/
|
|
350
|
+
private buildAggregationSort;
|
|
351
|
+
/**
|
|
352
|
+
* Compiles this query into an aggregation pipeline that materializes the same
|
|
353
|
+
* results as `find` would, but ordered by a deterministic per-document shuffle
|
|
354
|
+
* (see `orderByRandom`). The `$match` stage is exactly what `find` receives
|
|
355
|
+
* today (`build()`), so all filter logic and indexes carry over unchanged;
|
|
356
|
+
* `$skip`/`$limit` are applied *after* the deterministic sort, which is what
|
|
357
|
+
* makes paging stable.
|
|
358
|
+
*
|
|
359
|
+
* **Requires MongoDB server >= 8.0** (uses `$toHashedIndexKey`).
|
|
360
|
+
*
|
|
361
|
+
* @param pagination - Optional pagination settings
|
|
362
|
+
* @returns MongoDB aggregation pipeline stages
|
|
363
|
+
*/
|
|
364
|
+
buildPipeline<T>(pagination?: Pagination): Document[];
|
|
257
365
|
}
|
|
258
366
|
/**
|
|
259
367
|
* FlongoQueryBuilder provides a builder pattern wrapper around FlongoQuery
|
package/dist/flongoQuery.js
CHANGED
|
@@ -26,11 +26,30 @@ class FlongoQuery {
|
|
|
26
26
|
this.expressions = [];
|
|
27
27
|
/** Array of range queries (currently unused but reserved for future features) */
|
|
28
28
|
this.ranges = [];
|
|
29
|
+
/**
|
|
30
|
+
* Ordered list of sort keys. The first entry is the primary sort, subsequent
|
|
31
|
+
* entries act as tiebreakers (in order). Populated by `orderBy()`/`thenBy()`.
|
|
32
|
+
*/
|
|
33
|
+
this.sorts = [];
|
|
29
34
|
/** Array of queries to be combined with OR logic */
|
|
30
35
|
this.orQueries = [];
|
|
31
36
|
/** Array of queries to be combined with AND logic */
|
|
32
37
|
this.andQueries = [];
|
|
33
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Primary sort field, derived from `sorts[0]`.
|
|
41
|
+
* @deprecated Retained for backward compatibility; use `sorts`.
|
|
42
|
+
*/
|
|
43
|
+
get orderField() {
|
|
44
|
+
return this.sorts[0]?.field;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Primary sort direction, derived from `sorts[0]`.
|
|
48
|
+
* @deprecated Retained for backward compatibility; use `sorts`.
|
|
49
|
+
*/
|
|
50
|
+
get orderDirection() {
|
|
51
|
+
return this.sorts[0]?.direction;
|
|
52
|
+
}
|
|
34
53
|
/**
|
|
35
54
|
* Gets the most recently added expression for method chaining
|
|
36
55
|
* @private
|
|
@@ -352,16 +371,92 @@ class FlongoQuery {
|
|
|
352
371
|
// SORTING
|
|
353
372
|
// ===========================================
|
|
354
373
|
/**
|
|
355
|
-
* Sets the field and direction
|
|
374
|
+
* Sets the primary sort field and direction, resetting any previously
|
|
375
|
+
* configured sort keys (including tiebreakers added via `thenBy`).
|
|
356
376
|
* @param field - Field name to sort by
|
|
357
377
|
* @param direction - Sort direction (ascending or descending)
|
|
358
378
|
* @returns This query instance for chaining
|
|
359
379
|
*/
|
|
360
380
|
orderBy(field, direction) {
|
|
361
|
-
this.
|
|
362
|
-
this.orderDirection = direction;
|
|
381
|
+
this.sorts = field ? [{ field, direction }] : [];
|
|
363
382
|
return this;
|
|
364
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Appends a secondary (tertiary, …) sort key used as a tiebreaker after the
|
|
386
|
+
* primary `orderBy` field. Sort keys are applied in the order they are added,
|
|
387
|
+
* which is what makes deterministic pagination possible (e.g. appending a
|
|
388
|
+
* unique `_id` tiebreaker so `skip`/`limit` pages don't overlap).
|
|
389
|
+
*
|
|
390
|
+
* If called before any `orderBy`, the field becomes the primary sort.
|
|
391
|
+
* If the same field is added more than once, the latest direction wins
|
|
392
|
+
* (the field keeps its original position in the sort order).
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* new FlongoQuery()
|
|
396
|
+
* .orderBy('featured', SortDirection.Descending)
|
|
397
|
+
* .thenBy('_id', SortDirection.Ascending);
|
|
398
|
+
* // => sort: { featured: -1, _id: 1 }
|
|
399
|
+
*
|
|
400
|
+
* @param field - Field name to sort by
|
|
401
|
+
* @param direction - Sort direction (ascending or descending)
|
|
402
|
+
* @returns This query instance for chaining
|
|
403
|
+
*/
|
|
404
|
+
thenBy(field, direction) {
|
|
405
|
+
if (!field) {
|
|
406
|
+
return this;
|
|
407
|
+
}
|
|
408
|
+
const existing = this.sorts.find((s) => s.field === field);
|
|
409
|
+
if (existing) {
|
|
410
|
+
existing.direction = direction;
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
this.sorts.push({ field, direction });
|
|
414
|
+
}
|
|
415
|
+
return this;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Adds a deterministic, seeded random sort. Given the same seed the produced
|
|
419
|
+
* order is identical on every call, so `$skip`/`$limit` pagination stays
|
|
420
|
+
* gapless and non-overlapping across pages; change the seed and the whole set
|
|
421
|
+
* reshuffles. This is what enables fair, rotating list/browse orderings.
|
|
422
|
+
*
|
|
423
|
+
* Composes with `orderBy`/`thenBy`: sort keys apply in call order and the
|
|
424
|
+
* shuffle slots in at the position of *this* call, so
|
|
425
|
+
* `orderBy('featured', Descending).orderByRandom(seed)` pins featured docs to
|
|
426
|
+
* the top and shuffles within each tier. `_id` is always appended as a final
|
|
427
|
+
* tiebreaker so a total order (and therefore stable paging) is guaranteed even
|
|
428
|
+
* under hash collisions. Calling this alone shuffles the whole result set.
|
|
429
|
+
*
|
|
430
|
+
* The seed is caller-owned — Flongo does not decide rotation policy. Pass e.g.
|
|
431
|
+
* `Math.floor(Date.now() / DAY_MS)` for daily rotation, optionally mixed with a
|
|
432
|
+
* session/user id. Number and string seeds are normalized to a string, so `1`
|
|
433
|
+
* and `'1'` produce the same order.
|
|
434
|
+
*
|
|
435
|
+
* Implemented natively via the `$toHashedIndexKey` aggregation operator, so a
|
|
436
|
+
* query carrying a random sort executes via an aggregation pipeline instead of
|
|
437
|
+
* `find`. **Requires MongoDB server >= 8.0.**
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* new FlongoQuery()
|
|
441
|
+
* .where('enable').eq(true)
|
|
442
|
+
* .orderBy('featured', SortDirection.Descending)
|
|
443
|
+
* .orderByRandom(seed);
|
|
444
|
+
*
|
|
445
|
+
* @param seed - Seed controlling the shuffle (number or string)
|
|
446
|
+
* @returns This query instance for chaining
|
|
447
|
+
*/
|
|
448
|
+
orderByRandom(seed) {
|
|
449
|
+
this.randomSeed = String(seed);
|
|
450
|
+
this.randomSortPosition = this.sorts.length;
|
|
451
|
+
return this;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Whether this query carries a random sort and must execute via an aggregation
|
|
455
|
+
* pipeline (`buildPipeline`) rather than the normal `find` path.
|
|
456
|
+
*/
|
|
457
|
+
hasRandomSort() {
|
|
458
|
+
return this.randomSeed !== undefined;
|
|
459
|
+
}
|
|
365
460
|
// ===========================================
|
|
366
461
|
// LOGICAL OPERATORS
|
|
367
462
|
// ===========================================
|
|
@@ -486,16 +581,90 @@ class FlongoQuery {
|
|
|
486
581
|
mongodbOptions.skip = pagination.offset;
|
|
487
582
|
mongodbOptions.limit = pagination.count;
|
|
488
583
|
}
|
|
489
|
-
// Add sorting if specified
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
584
|
+
// Add sorting if specified. Iterate sort keys in order so MongoDB honors the
|
|
585
|
+
// insertion order of the resulting `sort` object's keys (primary first, then
|
|
586
|
+
// tiebreakers). Entries without a direction are skipped, preserving the prior
|
|
587
|
+
// behavior where a field set with no direction produced no sort.
|
|
588
|
+
const sortKeys = this.sorts.filter((s) => s.field && s.direction);
|
|
589
|
+
if (sortKeys.length) {
|
|
590
|
+
const sort = {};
|
|
591
|
+
for (const s of sortKeys) {
|
|
592
|
+
sort[s.field] = s.direction === types_1.SortDirection.Ascending ? 1 : -1;
|
|
593
|
+
}
|
|
594
|
+
mongodbOptions.sort = sort;
|
|
494
595
|
}
|
|
495
596
|
return mongodbOptions;
|
|
496
597
|
}
|
|
598
|
+
/**
|
|
599
|
+
* Builds the ordered `$sort` specification for the aggregation path, weaving
|
|
600
|
+
* the internal shuffle field in at its call position amongst the explicit
|
|
601
|
+
* sort keys and always appending `_id` as a final tiebreaker. Only used when
|
|
602
|
+
* a random sort is present.
|
|
603
|
+
* @private
|
|
604
|
+
*/
|
|
605
|
+
buildAggregationSort() {
|
|
606
|
+
const sort = {};
|
|
607
|
+
const addShuffle = () => {
|
|
608
|
+
sort[FlongoQuery.SHUFFLE_FIELD] = 1;
|
|
609
|
+
};
|
|
610
|
+
// Iterate the raw sort keys so the random slot lands at the exact index it
|
|
611
|
+
// was declared at (positions are captured against the unfiltered array).
|
|
612
|
+
this.sorts.forEach((s, i) => {
|
|
613
|
+
if (i === this.randomSortPosition) {
|
|
614
|
+
addShuffle();
|
|
615
|
+
}
|
|
616
|
+
if (s.field && s.direction) {
|
|
617
|
+
sort[s.field] = s.direction === types_1.SortDirection.Ascending ? 1 : -1;
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
// Random sort declared at or after the last explicit key.
|
|
621
|
+
if (this.randomSortPosition !== undefined && this.randomSortPosition >= this.sorts.length) {
|
|
622
|
+
addShuffle();
|
|
623
|
+
}
|
|
624
|
+
// Final tiebreaker guarantees a total order (gapless pages under collisions).
|
|
625
|
+
if (!("_id" in sort)) {
|
|
626
|
+
sort["_id"] = 1;
|
|
627
|
+
}
|
|
628
|
+
return sort;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Compiles this query into an aggregation pipeline that materializes the same
|
|
632
|
+
* results as `find` would, but ordered by a deterministic per-document shuffle
|
|
633
|
+
* (see `orderByRandom`). The `$match` stage is exactly what `find` receives
|
|
634
|
+
* today (`build()`), so all filter logic and indexes carry over unchanged;
|
|
635
|
+
* `$skip`/`$limit` are applied *after* the deterministic sort, which is what
|
|
636
|
+
* makes paging stable.
|
|
637
|
+
*
|
|
638
|
+
* **Requires MongoDB server >= 8.0** (uses `$toHashedIndexKey`).
|
|
639
|
+
*
|
|
640
|
+
* @param pagination - Optional pagination settings
|
|
641
|
+
* @returns MongoDB aggregation pipeline stages
|
|
642
|
+
*/
|
|
643
|
+
buildPipeline(pagination) {
|
|
644
|
+
const pipeline = [{ $match: this.build() }];
|
|
645
|
+
// Hash the full `_id` string mixed with the seed. Hashing the whole _id
|
|
646
|
+
// decorrelates the order from the ObjectId timestamp prefix, so newer docs
|
|
647
|
+
// aren't biased toward one end of the shuffle.
|
|
648
|
+
pipeline.push({
|
|
649
|
+
$addFields: {
|
|
650
|
+
[FlongoQuery.SHUFFLE_FIELD]: {
|
|
651
|
+
$toHashedIndexKey: { $concat: [{ $toString: "$_id" }, ":", this.randomSeed] }
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
pipeline.push({ $sort: this.buildAggregationSort() });
|
|
656
|
+
if (pagination) {
|
|
657
|
+
pipeline.push({ $skip: pagination.offset });
|
|
658
|
+
pipeline.push({ $limit: pagination.count });
|
|
659
|
+
}
|
|
660
|
+
// Don't leak the internal shuffle field to callers.
|
|
661
|
+
pipeline.push({ $project: { [FlongoQuery.SHUFFLE_FIELD]: 0 } });
|
|
662
|
+
return pipeline;
|
|
663
|
+
}
|
|
497
664
|
}
|
|
498
665
|
exports.FlongoQuery = FlongoQuery;
|
|
666
|
+
/** Internal computed field used to carry the per-document shuffle hash. */
|
|
667
|
+
FlongoQuery.SHUFFLE_FIELD = "__flongoShuffle";
|
|
499
668
|
/**
|
|
500
669
|
* FlongoQueryBuilder provides a builder pattern wrapper around FlongoQuery
|
|
501
670
|
* This can be useful for more complex query construction scenarios
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,6 @@ export { FlongoCollection, FlongoCollectionOptions } from "./flongoCollection";
|
|
|
2
2
|
export { FlongoQuery, FlongoQueryBuilder } from "./flongoQuery";
|
|
3
3
|
export { initializeFlongo, connectFlongo, FlongoConfig, flongoClient, flongoDb } from "./flongo";
|
|
4
4
|
export { Error404, Error400 } from "./errors";
|
|
5
|
-
export { Entity, DbRecord, Pagination, Coordinates, Bounds, Event, EventName, EventRecord, Logic, SortDirection, ColRange, ColExpression, ICollectionQuery, ICollection, Repository, CacheOptions, CachedCollectionOptions } from "./types";
|
|
5
|
+
export { Entity, DbRecord, Pagination, Coordinates, Bounds, Event, EventName, EventRecord, Logic, SortDirection, Sort, ColRange, ColExpression, ICollectionQuery, ICollection, Repository, CacheOptions, CachedCollectionOptions } from "./types";
|
|
6
6
|
export { CacheStore, CacheEntry, CacheStats, CacheStoreOptions, BaseCacheStore, MemoryCache, MemoryCacheOptions, CacheKeyGenerator, CacheKeyOptions, InvalidationStrategy, InvalidationRule, InvalidationOptions, CacheInvalidator, TTLStrategy, LRUStrategy, CacheConfig, CacheProviderConfig, CacheConfiguration, createDefaultConfig, createProductionConfig, createDevelopmentConfig, DetailedCacheStats, CacheMetrics, CacheStatsCollector, CacheMonitor, getGlobalCacheMonitor, resetGlobalCacheMonitor } from "./cache";
|
|
7
7
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/types.d.ts
CHANGED
|
@@ -58,10 +58,19 @@ export declare class ColExpression {
|
|
|
58
58
|
val: any;
|
|
59
59
|
constructor(key: string);
|
|
60
60
|
}
|
|
61
|
+
/** A single sort key: a field paired with a direction. */
|
|
62
|
+
export interface Sort {
|
|
63
|
+
field: string;
|
|
64
|
+
direction?: SortDirection;
|
|
65
|
+
}
|
|
61
66
|
export type ICollectionQuery = {
|
|
62
67
|
expressions: ColExpression[];
|
|
63
68
|
ranges: ColRange[];
|
|
69
|
+
/** Ordered list of sort keys. The first entry is the primary sort. */
|
|
70
|
+
sorts?: Sort[];
|
|
71
|
+
/** @deprecated Primary sort field; derived from `sorts[0]`. Use `sorts`. */
|
|
64
72
|
orderField?: string;
|
|
73
|
+
/** @deprecated Primary sort direction; derived from `sorts[0]`. Use `sorts`. */
|
|
65
74
|
orderDirection?: SortDirection;
|
|
66
75
|
orQueries: ICollectionQuery[];
|
|
67
76
|
andQueries: ICollectionQuery[];
|