@ignex/ninox 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -185,7 +185,7 @@ A quick tour — each entry links to the full guide further down.
185
185
  | ⚡ | [Cache + dedup](#performance-by-default) | 0 round trips on repeat reads; N identical reads → 1 DB call |
186
186
  | 🔗 | [Relations + DataLoader](#relations--populate) | `belongsTo` / `hasMany` / `manyToMany` without N+1 |
187
187
  | 📄 | [Pagination](#pagination-offset--keyset) | `$facet` (data + count, 1 round trip) + keyset cursors |
188
- | 📊 | [Typed aggregation](#typed-aggregation-pipeline) | Chainable `pipeline()` with inferred result types |
188
+ | 📊 | [Typed aggregation](#typed-aggregation-pipeline) | Chainable `pipeline()` with inferred result types — materialized results are cached |
189
189
  | 🔍 | [Search & joins](#search--joins) | `$text` / regex search + single-hop `lookupJoin` |
190
190
  | 🗺️ | [Geospatial](#geospatial) | `s.geoPoint()` + typed `$geoNear` with distances |
191
191
  | 📦 | [Repository layer](#repository-layer) | Optional domain-typed wrapper (`getById`, `create`, `page`, …) |
@@ -244,6 +244,17 @@ Terminals: `.toArray()`, `.first()`, `.cursor()`. The callback
244
244
  the schema); reach for `db.pipeline()` when you also want inferred result types.
245
245
  See [`examples/10-typed-pipeline.ts`](./examples/10-typed-pipeline.ts).
246
246
 
247
+ > ⚡ **Aggregation results are cached too.** `pipeline().toArray()/.first()`,
248
+ > `groupBy`, `dateRangeAnalysis`, `textSearch`, `lookupJoin`, and
249
+ > `paginateFlexible` route through the same write-through `QueryCache` as flat
250
+ > reads (warm reads = **0 driver calls**). Invalidation is write-through and
251
+ > per-source: a `$lookup`/`$unionWith` join is registered under **every** source
252
+ > collection, so a write to any of them drops it. Pipelines that write
253
+ > (`$out`/`$merge`) or are non-deterministic (`$sample`) are never cached; use
254
+ > `{ cache: false }` for other non-deterministic pipelines (`$rand`/`$function`).
255
+ > Live cursors — `aggregate()` and `pipeline().cursor()` — stream and are not
256
+ > cached.
257
+
247
258
  ## Query builder & full CRUD
248
259
 
249
260
  Fluent queries: `.where().or().sort().skip().limit().select().hint()` →
@@ -462,7 +473,10 @@ await runner.scaffold('add_tags'); // create a new NNN_add_tags.ts template
462
473
  The **default path is the fast path.** A `QueryCache` and in-flight dedup are
463
474
  created and enabled automatically — repeated identical reads become cache hits
464
475
  (0 driver calls) and identical concurrent reads coalesce into one driver call.
465
- Opt out when you need to:
476
+ The same applies to **materializing aggregation results** (`pipeline().toArray()`
477
+ / `.first()`, `groupBy`, `dateRangeAnalysis`, `textSearch`, `lookupJoin`,
478
+ `paginateFlexible`) — they share the same cache, with invalidation per source
479
+ collection. Opt out when you need to:
466
480
 
467
481
  ```ts
468
482
  // Service-wide: disable caching, dedup, or everything at once.
@@ -571,6 +585,32 @@ Cached results are shared **by reference** (zero clone overhead); if a caller mi
571
585
  mutate a result, enable per-query `clone: true` (or `cache: { clone: true }`) so
572
586
  each read returns a fresh copy and can't poison the cache.
573
587
 
588
+ ### Failure semantics — the staleness window
589
+
590
+ Freshness is best-effort under failure: while the background freshness
591
+ mechanism is down, the HotCache keeps serving from memory, so **low latency can
592
+ hide stale reads**. Here is exactly what happens in the failure cases that
593
+ matter, and how the cache self-heals:
594
+
595
+ | failure | what happens | recovery |
596
+ | --- | --- | --- |
597
+ | **Stream error / server kill** (network drop, replica failover) | watcher logs `hot cache change stream error`, closes the stream, retries with jittered backoff (1s → 5s) | on **reopen** the bound collection is invalidated once — entries that could have gone stale during the outage are re-fetched on the next read |
598
+ | **Resume-token expiry** (`ChangeStreamHistoryLost` — the oplog pruned past the stream's token) | treated as a *recoverable* error, NOT "unsupported" → same backoff/retry path; the re-opened stream has no resume token and starts from "now" | invalidate-on-reopen drops any entries that missed changes during the gap; the next read re-fetches |
599
+ | **Rollback / `invalidate` events** (collection dropped/renamed) | the stream emits `invalidate` and ends | the loop reconnects and invalidates on reopen |
600
+ | **Consumer downtime** (the process hosting the watcher is down/paused) | writes are NOT observed, so the in-process LRU keeps serving stale values | once the watcher reconnects, invalidate-on-reopen drops the collection's entries and the next read is fresh; a consumer that never reconnects leaves entries stale forever unless `ttlMs`/`refreshIntervalMs` are set |
601
+
602
+ To **bound the staleness window itself** (not just recover from it):
603
+
604
+ - set per-query `ttlMs` (expiry) and/or `refreshIntervalMs` (background
605
+ refresh) — `refreshIntervalMs` also keeps the cache self-updating even if the
606
+ watcher never comes back;
607
+ - watch `hot.stats()` (per-query `hits` / `misses` / `refreshes` / `loadErrors`)
608
+ and the `hot cache change stream reconnected` / `hot cache change stream
609
+ error` warnings — a rising `misses`-to-`hits` ratio or repeated reconnect
610
+ warnings mean the watcher is unhealthy;
611
+ - in standalone mode, queries with neither `refreshIntervalMs` nor `ttlMs` are
612
+ served until manually invalidated (see the standalone warning below).
613
+
574
614
  ## Background refresh system
575
615
 
576
616
  The standalone-mode workhorse behind the HotCache: a single global background
@@ -620,7 +660,7 @@ mapping:
620
660
 
621
661
  | React Query | ninox |
622
662
  | --- | --- |
623
- | `queryKey` | the cache key — HotCache: **query name + call parameters**; query cache: **collection + filter hash** |
663
+ | `queryKey` | the cache key — HotCache: **query name + call parameters**; query cache: **collection + filter/pipeline hash** |
624
664
  | `queryClient.invalidateQueries(['products'])` | `hot.invalidateCollection('products')` or `hot.invalidate('topProducts')` |
625
665
  | invalidating one exact entry | `hot.invalidateParams('topProducts', 3)` / `topProducts.invalidate(3)` |
626
666
  | `refetchInterval` (background refetch) | the standalone [background ticker](#background-refresh-system) (`refreshIntervalMs`) |
@@ -630,7 +670,8 @@ mapping:
630
670
  **What keeps the cache correct, by default:**
631
671
 
632
672
  1. **Write-through invalidation.** Every ORM insert/update/delete drops that
633
- collection's cached reads immediately — like React Query invalidating on a
673
+ collection's cached reads — and every cached aggregation result that reads it
674
+ (via its source collections) — immediately, like React Query invalidating on a
634
675
  mutation, but you never have to call it.
635
676
  2. **External-writer protection.** Writes from other processes, the raw `client`
636
677
  escape hatch, or direct DB access can't invalidate on their own. Three ways to
package/dist/index.d.ts CHANGED
@@ -273,7 +273,10 @@ declare const cacheCollectionKey: (dbName: string, physical: string) => string;
273
273
  * Read-through query cache with per-collection invalidation.
274
274
  *
275
275
  * Keys are `collection<sep>hash` so a single write can invalidate every cached
276
- * read for that collection. Invalidation is WRITE-THROUGH only: the ORM drops a
276
+ * read for that collection. `set()` can also register an entry under ADDITIONAL
277
+ * collection keys (see `collections`) so derived reads — e.g. an aggregation
278
+ * with `$lookup`/`$unionWith` — are invalidated when ANY of their source
279
+ * collections changes. Invalidation is WRITE-THROUGH only: the ORM drops a
277
280
  * collection's entries on every write it performs. Reads are NOT invalidated by
278
281
  * external writers (other processes, the raw `client` escape hatch, or direct
279
282
  * DB writes) — with `ttlMs: 0` (default) those reads stay stale indefinitely,
@@ -283,6 +286,8 @@ declare const cacheCollectionKey: (dbName: string, physical: string) => string;
283
286
  declare class QueryCache {
284
287
  private lru;
285
288
  private index;
289
+ /** Per-collection version — bumped on every invalidation/clear of that collection. */
290
+ private colVersions;
286
291
  private ttlMs;
287
292
  private clone;
288
293
  private hits;
@@ -297,8 +302,14 @@ declare class QueryCache {
297
302
  stats(): QueryCacheStats;
298
303
  /** Compose a cache key for a collection + filter/options hash. */
299
304
  key(collection: string, filterHash: string): string;
305
+ /**
306
+ * Current version of a collection key (db-namespaced, e.g. from
307
+ * `cacheCollectionKey`). Reads capture it BEFORE fetching and pass it to
308
+ * `set()` so a concurrent invalidation can never be hidden by a late write.
309
+ */
310
+ versionOf(collection: string): number;
300
311
  get(key: string): unknown | undefined;
301
- set(key: string, value: unknown, ttlMs?: number): void;
312
+ set(key: string, value: unknown, ttlMs?: number, collections?: string[], versions?: Record<string, number>): void;
302
313
  delete(key: string): void;
303
314
  /** Drop every cached entry belonging to a physical collection. */
304
315
  invalidateByCollection(collection: string): void;
@@ -1350,6 +1361,84 @@ interface DbOpMeta {
1350
1361
  op: string;
1351
1362
  }
1352
1363
 
1364
+ /**
1365
+ * Aggregation types + the shared context handed to the aggregation op-group
1366
+ * factories (`./aggregate.ts`, `./group.ts`, `./text-search.ts`,
1367
+ * `./lookup-join.ts`). Type-only plus the `AggregationCtx` interface.
1368
+ */
1369
+
1370
+ interface GroupByConfig {
1371
+ /** Group key: a field name or a document of `$`-expressions. */
1372
+ groupBy: string | Record<string, unknown>;
1373
+ /** Accumulator expressions, e.g. `{ revenue: { $sum: '$total' } }`. */
1374
+ accumulate?: Record<string, unknown>;
1375
+ /** Sort the grouped output. */
1376
+ sort?: Record<string, 1 | -1>;
1377
+ /** Cap the number of groups returned. */
1378
+ limit?: number;
1379
+ }
1380
+ interface DateRangeConfig {
1381
+ /** Date field to bucket on (must exist on every doc). */
1382
+ dateField: string;
1383
+ /** Inclusive range start. */
1384
+ startDate: Date;
1385
+ /** Inclusive range end. */
1386
+ endDate: Date;
1387
+ /** Bucket granularity (default `day`). */
1388
+ granularity?: 'hour' | 'day' | 'week' | 'month' | 'year';
1389
+ }
1390
+ interface LookupConfig {
1391
+ /** Related (logical) collection to join. */
1392
+ fromCollection: string;
1393
+ /** Field on the source docs holding the foreign key. */
1394
+ localField: string;
1395
+ /** Field on the foreign docs to match. */
1396
+ foreignField: string;
1397
+ /** Output field name for the joined array. */
1398
+ as: string;
1399
+ /** Optional `$pipeline` limiting/filtering the joined docs. */
1400
+ pipeline?: Document[];
1401
+ /** When true, `$unwind` the joined array (preserving nulls). */
1402
+ unwindSingle?: boolean;
1403
+ }
1404
+ interface PipelineCustomization {
1405
+ /** Stages prepended before the generated aggregation. */
1406
+ prePipeline?: Document[];
1407
+ /** Stages appended after the generated aggregation. */
1408
+ postPipeline?: Document[];
1409
+ }
1410
+ interface AggregationOpsOptions {
1411
+ resolveCollectionName: (logical: string) => string;
1412
+ wrapMongoErrors?: boolean;
1413
+ /** Shared read cache (`undefined` = caching disabled). */
1414
+ cache?: QueryCache;
1415
+ /** Service default for in-flight read dedup. */
1416
+ dedupeReads: boolean;
1417
+ /** Shared in-flight dedup (`undefined` = no dedup). */
1418
+ inFlight?: InFlight;
1419
+ }
1420
+ /**
1421
+ * The cached-aggregation runner every materializing op uses (see
1422
+ * `cached-read.ts`). Caches results in the shared `QueryCache` (write-through,
1423
+ * invalidated per source collection) and dedupes identical in-flight calls.
1424
+ */
1425
+ type CachedAggregate = <X extends string, T>(input: {
1426
+ collection: X;
1427
+ opName: string;
1428
+ pipeline: Document[];
1429
+ options?: AggregateOptions & QueryOptions;
1430
+ /** Additional LOGICAL source collections (e.g. `lookupJoin` fromCollections). */
1431
+ sources?: string[];
1432
+ execute: (resolved: ResolvedQueryOptions) => Promise<T>;
1433
+ }) => Promise<T>;
1434
+ /** Pagination subset used by `textSearch`. */
1435
+ interface TextSearchPagination {
1436
+ page?: number;
1437
+ limit?: number;
1438
+ sort?: Record<string, 1 | -1>;
1439
+ maxLimit?: number;
1440
+ }
1441
+
1353
1442
  interface CrudOpDeps<TCollection> {
1354
1443
  trace: <T>(meta: DbOpMeta, fn: () => T | Promise<T>) => Promise<T>;
1355
1444
  meta: (collection: TCollection, op: string) => DbOpMeta;
@@ -1570,6 +1659,12 @@ interface PipelineBuilderDeps {
1570
1659
  hint?: Hint;
1571
1660
  }) => AggregateOptions;
1572
1661
  options?: AggregateOptions & QueryOptions;
1662
+ /**
1663
+ * Cached-aggregation runner (write-through cache + dedup). Present on real
1664
+ * builders so `.toArray()` / `.first()` are cached; absent on stage-only
1665
+ * builders (sub-pipelines read via `.raw()` and never execute).
1666
+ */
1667
+ cachedAggregate?: CachedAggregate;
1573
1668
  }
1574
1669
  declare class PipelineBuilder<TClients extends DbClientsDefinition, TDb extends ExtractDbNames<TClients>, TDoc extends Document> {
1575
1670
  private readonly deps;
@@ -1959,68 +2054,14 @@ interface SearchConfig {
1959
2054
  sortByScore?: boolean;
1960
2055
  }
1961
2056
 
1962
- /**
1963
- * Aggregation types + the shared context handed to the aggregation op-group
1964
- * factories (`./aggregate.ts`, `./group.ts`, `./text-search.ts`,
1965
- * `./lookup-join.ts`). Type-only plus the `AggregationCtx` interface.
1966
- */
1967
-
1968
- interface GroupByConfig {
1969
- /** Group key: a field name or a document of `$`-expressions. */
1970
- groupBy: string | Record<string, unknown>;
1971
- /** Accumulator expressions, e.g. `{ revenue: { $sum: '$total' } }`. */
1972
- accumulate?: Record<string, unknown>;
1973
- /** Sort the grouped output. */
1974
- sort?: Record<string, 1 | -1>;
1975
- /** Cap the number of groups returned. */
1976
- limit?: number;
1977
- }
1978
- interface DateRangeConfig {
1979
- /** Date field to bucket on (must exist on every doc). */
1980
- dateField: string;
1981
- /** Inclusive range start. */
1982
- startDate: Date;
1983
- /** Inclusive range end. */
1984
- endDate: Date;
1985
- /** Bucket granularity (default `day`). */
1986
- granularity?: 'hour' | 'day' | 'week' | 'month' | 'year';
1987
- }
1988
- interface LookupConfig {
1989
- /** Related (logical) collection to join. */
1990
- fromCollection: string;
1991
- /** Field on the source docs holding the foreign key. */
1992
- localField: string;
1993
- /** Field on the foreign docs to match. */
1994
- foreignField: string;
1995
- /** Output field name for the joined array. */
1996
- as: string;
1997
- /** Optional `$pipeline` limiting/filtering the joined docs. */
1998
- pipeline?: Document[];
1999
- /** When true, `$unwind` the joined array (preserving nulls). */
2000
- unwindSingle?: boolean;
2001
- }
2002
- interface PipelineCustomization {
2003
- /** Stages prepended before the generated aggregation. */
2004
- prePipeline?: Document[];
2005
- /** Stages appended after the generated aggregation. */
2006
- postPipeline?: Document[];
2007
- }
2008
- interface AggregationOpsOptions {
2009
- resolveCollectionName: (logical: string) => string;
2010
- wrapMongoErrors?: boolean;
2011
- }
2012
- /** Pagination subset used by `textSearch`. */
2013
- interface TextSearchPagination {
2014
- page?: number;
2015
- limit?: number;
2016
- sort?: Record<string, 1 | -1>;
2017
- maxLimit?: number;
2018
- }
2019
-
2020
2057
  /**
2021
2058
  * Aggregation ops for one database: `aggregate`, `pipeline`, `groupBy`,
2022
2059
  * `textSearch`, `dateRangeAnalysis`, `lookupJoin`. All collection-name first
2023
- * and schema-typed.
2060
+ * and schema-typed. Materializing ops (`pipeline().toArray()/.first()`,
2061
+ * `groupBy`, `dateRangeAnalysis`, `textSearch`, `lookupJoin`) route through the
2062
+ * shared cached-aggregation runner (`cachedAggregate`) — write-through cache +
2063
+ * in-flight dedup, invalidated per source collection. `aggregate()` and
2064
+ * `pipeline().cursor()` stream live cursors and stay uncached.
2024
2065
  */
2025
2066
  declare const makeAggregationOps: <TClients extends DbClientsDefinition, TDb extends ExtractDbNames<TClients>>(client: Db, dbLabel: string, logger: LoggerLike, opts: AggregationOpsOptions) => {
2026
2067
  lookupJoin: <X extends ExtractCollectionNames<TClients, TDb>, TResult extends Document = CollectionDoc<TClients[TDb]["collections"][X]>>(collection: X, baseFilter: FilterInput<CollectionDoc<TClients[TDb]["collections"][X]>>, lookups: LookupConfig[], customization?: PipelineCustomization, options?: mongodb.AggregateOptions & QueryOptions) => Promise<TResult[]>;
@@ -2133,11 +2174,20 @@ interface PaginationOpsOptions {
2133
2174
  drift?: DriftMode;
2134
2175
  /** Resolve the declared schema for a logical collection (drift detection). */
2135
2176
  getSchema?: (logical: string) => ObjectField | undefined;
2177
+ /** Shared read cache (`undefined` = caching disabled). */
2178
+ cache?: QueryCache;
2179
+ /** Service default for in-flight read dedup. */
2180
+ dedupeReads: boolean;
2181
+ /** Shared in-flight dedup (`undefined` = no dedup). */
2182
+ inFlight?: InFlight;
2136
2183
  }
2137
2184
 
2138
2185
  /**
2139
2186
  * Pagination ops for one database: `paginateFlexible` (offset, `$facet`) and
2140
2187
  * `paginateCursor` (keyset). Both are collection-name first and schema-typed.
2188
+ * `paginateFlexible` routes through the shared cached-aggregation runner
2189
+ * (write-through cache + dedup); `paginateCursor` is a find-based keyset read
2190
+ * and stays uncached (its opaque cursor pages are largely unique).
2141
2191
  */
2142
2192
  declare const makePaginationOps: <TClients extends DbClientsDefinition, TDb extends ExtractDbNames<TClients>>(client: Db, dbLabel: string, logger: LoggerLike, opts: PaginationOpsOptions) => {
2143
2193
  paginateCursor: <X extends ExtractCollectionNames<TClients, TDb>>(collection: X, baseFilter: FilterInput<CollectionDoc<TClients[TDb]["collections"][X]>>, config: CursorPaginationConfig<CollectionDoc<TClients[TDb]["collections"][X]>>) => Promise<CursorPage<CollectionDoc<TClients[TDb]["collections"][X]>>>;