@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 +45 -4
- package/dist/index.d.ts +111 -61
- package/dist/index.js +218 -63
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
|
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.
|
|
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]>>>;
|