flongo 1.8.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 CHANGED
@@ -118,6 +118,55 @@ const page = await collection.getAll(
118
118
  );
119
119
  ```
120
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
+ ]);
168
+ ```
169
+
121
170
  ### Collection Operations
122
171
 
123
172
  ```typescript
@@ -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
@@ -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));
@@ -24,6 +24,22 @@ export declare class FlongoQuery implements ICollectionQuery {
24
24
  * entries act as tiebreakers (in order). Populated by `orderBy()`/`thenBy()`.
25
25
  */
26
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";
27
43
  /**
28
44
  * Primary sort field, derived from `sorts[0]`.
29
45
  * @deprecated Retained for backward compatibility; use `sorts`.
@@ -263,6 +279,43 @@ export declare class FlongoQuery implements ICollectionQuery {
263
279
  * @returns This query instance for chaining
264
280
  */
265
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;
266
319
  /**
267
320
  * Adds a sub-query with AND logic
268
321
  * @param query - Sub-query to combine with AND logic
@@ -287,6 +340,28 @@ export declare class FlongoQuery implements ICollectionQuery {
287
340
  * @returns MongoDB FindOptions object
288
341
  */
289
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[];
290
365
  }
291
366
  /**
292
367
  * FlongoQueryBuilder provides a builder pattern wrapper around FlongoQuery
@@ -414,6 +414,49 @@ class FlongoQuery {
414
414
  }
415
415
  return this;
416
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
+ }
417
460
  // ===========================================
418
461
  // LOGICAL OPERATORS
419
462
  // ===========================================
@@ -552,8 +595,76 @@ class FlongoQuery {
552
595
  }
553
596
  return mongodbOptions;
554
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
+ }
555
664
  }
556
665
  exports.FlongoQuery = FlongoQuery;
666
+ /** Internal computed field used to carry the per-document shuffle hash. */
667
+ FlongoQuery.SHUFFLE_FIELD = "__flongoShuffle";
557
668
  /**
558
669
  * FlongoQueryBuilder provides a builder pattern wrapper around FlongoQuery
559
670
  * This can be useful for more complex query construction scenarios
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flongo",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Firestore-like fluent query API for MongoDB",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",