@ignex/ninox 0.1.2 → 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/dist/index.js CHANGED
@@ -513,7 +513,10 @@ var WatchCoordinator = class {
513
513
  * Long-lived change-stream consumer for one collection. Each data change
514
514
  * invalidates the queries bound to it. On permanent "not supported" errors the
515
515
  * watcher is dropped and, if every planned stream failed, the cache falls back
516
- * to the standalone ticker. Transient errors retry with backoff.
516
+ * to the standalone ticker. Transient errors (including resume-token expiry,
517
+ * rollbacks, and `invalidate` events) retry with backoff and invalidate the
518
+ * collection on reopen so entries missed during the outage are re-fetched —
519
+ * see the module doc for the full failure-semantics contract.
517
520
  */
518
521
  async watchLoop(key, ref) {
519
522
  let backoff = 0;
@@ -548,7 +551,7 @@ var WatchCoordinator = class {
548
551
  } catch {
549
552
  }
550
553
  }
551
- if (/only supported on replica sets|not supported|ChangeStream/i.test(message)) {
554
+ if (/only supported on replica sets/i.test(message)) {
552
555
  this.markStreamFailed(key);
553
556
  return;
554
557
  }
@@ -880,6 +883,8 @@ var cacheCollectionKey = (dbName, physical) => `${dbName}${NS_SEP}${physical}`;
880
883
  var QueryCache = class {
881
884
  lru;
882
885
  index = /* @__PURE__ */ new Map();
886
+ /** Per-collection version — bumped on every invalidation/clear of that collection. */
887
+ colVersions = /* @__PURE__ */ new Map();
883
888
  ttlMs;
884
889
  clone;
885
890
  hits = 0;
@@ -914,6 +919,14 @@ var QueryCache = class {
914
919
  key(collection, filterHash) {
915
920
  return `${collection}${SEP}${filterHash}`;
916
921
  }
922
+ /**
923
+ * Current version of a collection key (db-namespaced, e.g. from
924
+ * `cacheCollectionKey`). Reads capture it BEFORE fetching and pass it to
925
+ * `set()` so a concurrent invalidation can never be hidden by a late write.
926
+ */
927
+ versionOf(collection) {
928
+ return this.colVersions.get(collection) ?? 0;
929
+ }
917
930
  get(key) {
918
931
  const entry = this.lru.get(key);
919
932
  if (!entry) {
@@ -925,22 +938,34 @@ var QueryCache = class {
925
938
  this.misses++;
926
939
  return void 0;
927
940
  }
941
+ if (entry.versions) {
942
+ for (const [col, ver] of Object.entries(entry.versions)) {
943
+ if (this.versionOf(col) !== ver) {
944
+ this.lru.delete(key);
945
+ this.misses++;
946
+ return void 0;
947
+ }
948
+ }
949
+ }
928
950
  this.hits++;
929
951
  return this.clone ? cloneDeep(entry.value) : entry.value;
930
952
  }
931
- set(key, value, ttlMs) {
953
+ set(key, value, ttlMs, collections, versions) {
932
954
  this.sets++;
933
955
  const ttl = ttlMs ?? this.ttlMs;
934
956
  const expiresAt = ttl > 0 ? Date.now() + ttl : Infinity;
935
- this.lru.set(key, { value, expiresAt });
957
+ this.lru.set(key, { value, expiresAt, versions });
936
958
  const sep = key.indexOf(SEP);
937
- const collection = sep === -1 ? key : key.slice(0, sep);
938
- let keys = this.index.get(collection);
939
- if (!keys) {
940
- keys = /* @__PURE__ */ new Set();
941
- this.index.set(collection, keys);
959
+ const primary = sep === -1 ? key : key.slice(0, sep);
960
+ const collectionKeys = /* @__PURE__ */ new Set([primary, ...collections ?? []]);
961
+ for (const collection of collectionKeys) {
962
+ let keys = this.index.get(collection);
963
+ if (!keys) {
964
+ keys = /* @__PURE__ */ new Set();
965
+ this.index.set(collection, keys);
966
+ }
967
+ keys.add(key);
942
968
  }
943
- keys.add(key);
944
969
  }
945
970
  delete(key) {
946
971
  this.deletes++;
@@ -948,6 +973,7 @@ var QueryCache = class {
948
973
  }
949
974
  /** Drop every cached entry belonging to a physical collection. */
950
975
  invalidateByCollection(collection) {
976
+ this.colVersions.set(collection, (this.colVersions.get(collection) ?? 0) + 1);
951
977
  const keys = this.index.get(collection);
952
978
  if (!keys) return;
953
979
  this.invalidateEvents++;
@@ -956,6 +982,9 @@ var QueryCache = class {
956
982
  }
957
983
  clear() {
958
984
  this.clearEvents++;
985
+ for (const collection of this.index.keys()) {
986
+ this.colVersions.set(collection, (this.colVersions.get(collection) ?? 0) + 1);
987
+ }
959
988
  this.lru.clear();
960
989
  this.index.clear();
961
990
  }
@@ -2664,28 +2693,48 @@ var PipelineBuilder = class _PipelineBuilder {
2664
2693
  /* ------------------------------ terminals ---------------------------- */
2665
2694
  /** Run the pipeline and return every output document. */
2666
2695
  toArray() {
2696
+ const run = (r) => this.deps.coll().aggregate(this.stages, this.deps.mergeDriver(r.driverOpts, r.sdk)).toArray();
2697
+ if (this.deps.cachedAggregate) {
2698
+ return this.deps.cachedAggregate({
2699
+ collection: this.deps.logical,
2700
+ opName: "mongo.pipeline",
2701
+ pipeline: this.stages,
2702
+ options: this.deps.options,
2703
+ execute: run
2704
+ });
2705
+ }
2667
2706
  return defineCrudOp(
2668
2707
  this.deps.crudDeps,
2669
2708
  this.deps.logical,
2670
2709
  "mongo.pipeline",
2671
- (r) => this.deps.coll().aggregate(this.stages, this.deps.mergeDriver(r.driverOpts, r.sdk)).toArray(),
2710
+ run,
2672
2711
  this.deps.options
2673
2712
  );
2674
2713
  }
2675
2714
  /** Run the pipeline and return the first output document (or null). */
2676
2715
  async first() {
2716
+ const run = async (r) => {
2717
+ const cursor = this.deps.coll().aggregate(this.stages, this.deps.mergeDriver(r.driverOpts, r.sdk));
2718
+ try {
2719
+ return await cursor.tryNext();
2720
+ } finally {
2721
+ await cursor.close();
2722
+ }
2723
+ };
2724
+ if (this.deps.cachedAggregate) {
2725
+ return this.deps.cachedAggregate({
2726
+ collection: this.deps.logical,
2727
+ opName: "mongo.pipeline.first",
2728
+ pipeline: this.stages,
2729
+ options: this.deps.options,
2730
+ execute: run
2731
+ });
2732
+ }
2677
2733
  return defineCrudOp(
2678
2734
  this.deps.crudDeps,
2679
2735
  this.deps.logical,
2680
- "mongo.pipeline",
2681
- async (r) => {
2682
- const cursor = this.deps.coll().aggregate(this.stages, this.deps.mergeDriver(r.driverOpts, r.sdk));
2683
- try {
2684
- return await cursor.tryNext();
2685
- } finally {
2686
- await cursor.close();
2687
- }
2688
- },
2736
+ "mongo.pipeline.first",
2737
+ run,
2689
2738
  this.deps.options
2690
2739
  );
2691
2740
  }
@@ -2797,7 +2846,7 @@ var buildAggregationStages = () => {
2797
2846
 
2798
2847
  // src/service/aggregation/aggregate.ts
2799
2848
  var makeAggregateOps = (ctx) => {
2800
- const { deps: deps2, coll, mergeAggOptions: mergeAggOptions2 } = ctx;
2849
+ const { deps: deps2, coll, mergeAggOptions: mergeAggOptions2, cachedAggregate } = ctx;
2801
2850
  const aggregate = async (collection, pipelineBuilder, options) => {
2802
2851
  const stages = buildAggregationStages();
2803
2852
  const pipeline2 = pipelineBuilder(stages);
@@ -2814,7 +2863,8 @@ var makeAggregateOps = (ctx) => {
2814
2863
  coll: () => coll(collection),
2815
2864
  crudDeps: deps2,
2816
2865
  mergeDriver: mergeAggOptions2,
2817
- options
2866
+ options,
2867
+ cachedAggregate
2818
2868
  });
2819
2869
  return { aggregate, pipeline };
2820
2870
  };
@@ -2834,10 +2884,103 @@ var DATE_PART_FORMATS = {
2834
2884
  month: "%Y-%m",
2835
2885
  year: "%Y"
2836
2886
  };
2887
+ var collectAggSources = (primaryLogical, pipeline, extraLogical = []) => {
2888
+ const sources = /* @__PURE__ */ new Set([primaryLogical, ...extraLogical]);
2889
+ const visit = (stages) => {
2890
+ if (!Array.isArray(stages)) return;
2891
+ for (const stage of stages) {
2892
+ if (!stage || typeof stage !== "object") continue;
2893
+ const s2 = stage;
2894
+ const lookup = s2.$lookup;
2895
+ if (lookup && typeof lookup === "object") {
2896
+ if (typeof lookup.from === "string") sources.add(lookup.from);
2897
+ if (Array.isArray(lookup.pipeline)) visit(lookup.pipeline);
2898
+ }
2899
+ const union = s2.$unionWith;
2900
+ if (union && typeof union === "object") {
2901
+ if (typeof union.coll === "string") sources.add(union.coll);
2902
+ if (Array.isArray(union.pipeline)) visit(union.pipeline);
2903
+ }
2904
+ const facet = s2.$facet;
2905
+ if (facet && typeof facet === "object") {
2906
+ for (const branch of Object.values(facet)) visit(branch);
2907
+ }
2908
+ }
2909
+ };
2910
+ visit(pipeline);
2911
+ return [...sources];
2912
+ };
2913
+ var isCacheablePipeline = (pipeline) => {
2914
+ for (const stage of pipeline) {
2915
+ if (!stage || typeof stage !== "object") continue;
2916
+ if ("$out" in stage || "$merge" in stage || "$sample" in stage) return false;
2917
+ const s2 = stage;
2918
+ const lookup = s2.$lookup;
2919
+ if (lookup && Array.isArray(lookup.pipeline) && !isCacheablePipeline(lookup.pipeline)) {
2920
+ return false;
2921
+ }
2922
+ const facet = s2.$facet;
2923
+ if (facet && typeof facet === "object") {
2924
+ for (const branch of Object.values(facet)) {
2925
+ if (Array.isArray(branch) && !isCacheablePipeline(branch)) return false;
2926
+ }
2927
+ }
2928
+ }
2929
+ return true;
2930
+ };
2931
+
2932
+ // src/service/aggregation/cached-read.ts
2933
+ var createCachedAggregate = (ctx) => {
2934
+ const { client, deps: deps2, opts, resolve } = ctx;
2935
+ const cache = opts.cache;
2936
+ return async (input) => {
2937
+ const { collection: collectionName, opName, pipeline, options, sources = [], execute } = input;
2938
+ const collection = collectionName;
2939
+ const physical = resolve(String(collection));
2940
+ const resolved = resolveQueryOptions(options);
2941
+ const noSession = !resolved.sdk.session;
2942
+ const useCache = cache !== void 0 && noSession && resolved.cache !== false && isCacheablePipeline(pipeline);
2943
+ const shouldDedupe = resolved.dedupe !== false && (resolved.dedupe === true || opts.dedupeReads === true && noSession) && opts.inFlight !== void 0;
2944
+ const runDriver = () => defineCrudOp(deps2, collection, opName, execute, options);
2945
+ const key = useCache ? cache.key(
2946
+ cacheCollectionKey(client.databaseName, physical),
2947
+ stableHash([opName, pipeline, options])
2948
+ ) : void 0;
2949
+ const toSourceKeys = () => collectAggSources(String(collection), pipeline, sources).map((name) => {
2950
+ try {
2951
+ return cacheCollectionKey(client.databaseName, resolve(name));
2952
+ } catch {
2953
+ return cacheCollectionKey(client.databaseName, name);
2954
+ }
2955
+ });
2956
+ const runOnce = () => {
2957
+ if (useCache && key) {
2958
+ const sourceKeys = toSourceKeys();
2959
+ const primaryKey = cacheCollectionKey(client.databaseName, physical);
2960
+ const versions = { [primaryKey]: cache.versionOf(primaryKey) };
2961
+ for (const col of sourceKeys) {
2962
+ if (!(col in versions)) versions[col] = cache.versionOf(col);
2963
+ }
2964
+ const hit = cache.get(key);
2965
+ if (hit !== void 0) return Promise.resolve(hit);
2966
+ return runDriver().then((value) => {
2967
+ cache.set(key, value, void 0, sourceKeys, versions);
2968
+ return value;
2969
+ });
2970
+ }
2971
+ return runDriver();
2972
+ };
2973
+ if (shouldDedupe) {
2974
+ const dedupeKey = `${cacheCollectionKey(client.databaseName, physical)}|${opName}|${stableHash([pipeline, options])}`;
2975
+ return opts.inFlight.run(dedupeKey, runOnce);
2976
+ }
2977
+ return runOnce();
2978
+ };
2979
+ };
2837
2980
 
2838
2981
  // src/service/aggregation/group.ts
2839
2982
  var makeGroupOps = (ctx) => {
2840
- const { deps: deps2, coll, mergeAggOptions: mergeAggOptions2 } = ctx;
2983
+ const { coll, mergeAggOptions: mergeAggOptions2, cachedAggregate } = ctx;
2841
2984
  const groupBy = async (collection, baseFilter, config, customization = {}, options) => {
2842
2985
  if (config.limit !== void 0 && (!Number.isFinite(config.limit) || config.limit < 0)) {
2843
2986
  throw new BadRequest("groupBy: `limit` must be a non-negative finite number");
@@ -2850,13 +2993,13 @@ var makeGroupOps = (ctx) => {
2850
2993
  ...config.limit !== void 0 ? [{ $limit: config.limit }] : [],
2851
2994
  ...customization.postPipeline ?? []
2852
2995
  ];
2853
- return defineCrudOp(
2854
- deps2,
2996
+ return cachedAggregate({
2855
2997
  collection,
2856
- "mongo.groupBy",
2857
- (r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray(),
2858
- options
2859
- );
2998
+ opName: "mongo.groupBy",
2999
+ pipeline,
3000
+ options,
3001
+ execute: (r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray()
3002
+ });
2860
3003
  };
2861
3004
  const dateRangeAnalysis = async (collection, baseFilter, dateConfig, analysisConfig = {}, customization = {}, options) => {
2862
3005
  if (analysisConfig.limit !== void 0 && (!Number.isFinite(analysisConfig.limit) || analysisConfig.limit < 0)) {
@@ -2880,20 +3023,20 @@ var makeGroupOps = (ctx) => {
2880
3023
  ...analysisConfig.limit !== void 0 ? [{ $limit: analysisConfig.limit }] : [],
2881
3024
  ...customization.postPipeline ?? []
2882
3025
  ];
2883
- return defineCrudOp(
2884
- deps2,
3026
+ return cachedAggregate({
2885
3027
  collection,
2886
- "mongo.dateRangeAnalysis",
2887
- (r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray(),
2888
- options
2889
- );
3028
+ opName: "mongo.dateRangeAnalysis",
3029
+ pipeline,
3030
+ options,
3031
+ execute: (r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray()
3032
+ });
2890
3033
  };
2891
3034
  return { groupBy, dateRangeAnalysis };
2892
3035
  };
2893
3036
 
2894
3037
  // src/service/aggregation/lookup-join.ts
2895
3038
  var makeLookupJoinOp = (ctx) => {
2896
- const { deps: deps2, coll, mergeAggOptions: mergeAggOptions2, resolve } = ctx;
3039
+ const { coll, mergeAggOptions: mergeAggOptions2, resolve, cachedAggregate } = ctx;
2897
3040
  const lookupJoin = async (collection, baseFilter, lookups, customization = {}, options) => {
2898
3041
  const pipeline = [
2899
3042
  ...customization.prePipeline ?? [],
@@ -2912,13 +3055,15 @@ var makeLookupJoinOp = (ctx) => {
2912
3055
  })),
2913
3056
  ...customization.postPipeline ?? []
2914
3057
  ];
2915
- return defineCrudOp(
2916
- deps2,
3058
+ return cachedAggregate({
2917
3059
  collection,
2918
- "mongo.lookupJoin",
2919
- (r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray(),
2920
- options
2921
- );
3060
+ opName: "mongo.lookupJoin",
3061
+ pipeline,
3062
+ options,
3063
+ // Writes to any joined collection must invalidate the cached join.
3064
+ sources: lookups.map((l) => l.fromCollection),
3065
+ execute: (r) => coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray()
3066
+ });
2922
3067
  };
2923
3068
  return { lookupJoin };
2924
3069
  };
@@ -2985,7 +3130,7 @@ var buildSearchStages = (baseFilter, searchConfig) => searchConfig.useRegex ? bu
2985
3130
 
2986
3131
  // src/service/aggregation/text-search.ts
2987
3132
  var makeTextSearchOp = (ctx) => {
2988
- const { deps: deps2, coll, mergeAggOptions: mergeAggOptions2 } = ctx;
3133
+ const { coll, mergeAggOptions: mergeAggOptions2, cachedAggregate } = ctx;
2989
3134
  const textSearch = async (collection, baseFilter, searchConfig, paginationConfig = {}, customization = {}, options) => {
2990
3135
  const normalized = normalizePageLimit(
2991
3136
  { page: paginationConfig.page, limit: paginationConfig.limit },
@@ -3011,19 +3156,19 @@ var makeTextSearchOp = (ctx) => {
3011
3156
  }
3012
3157
  }
3013
3158
  ];
3014
- return defineCrudOp(
3015
- deps2,
3159
+ return cachedAggregate({
3016
3160
  collection,
3017
- "mongo.textSearch",
3018
- async (r) => {
3161
+ opName: "mongo.textSearch",
3162
+ pipeline,
3163
+ options,
3164
+ execute: async (r) => {
3019
3165
  const rows = await coll(collection).aggregate(pipeline, mergeAggOptions2(r.driverOpts, r.sdk)).toArray();
3020
3166
  const row = rows[0];
3021
3167
  const data = row?.data ?? [];
3022
3168
  const totalCount = row?.totalCount?.[0]?.count ?? 0;
3023
3169
  return buildPaginationResult(data, totalCount, normalized.page, normalized.limit);
3024
- },
3025
- options
3026
- );
3170
+ }
3171
+ });
3027
3172
  };
3028
3173
  return { textSearch };
3029
3174
  };
@@ -3037,7 +3182,8 @@ var makeAggregationOps = (client, dbLabel, logger, opts) => {
3037
3182
  resolve,
3038
3183
  deps: deps2,
3039
3184
  coll,
3040
- mergeAggOptions
3185
+ mergeAggOptions,
3186
+ cachedAggregate: createCachedAggregate({ client, deps: deps2, opts, resolve })
3041
3187
  };
3042
3188
  return {
3043
3189
  ...makeAggregateOps(ctx),
@@ -3155,10 +3301,12 @@ var createCrudContext = (client, dbLabel, logger, opts) => {
3155
3301
  });
3156
3302
  const runOnce = () => {
3157
3303
  if (useCache && key) {
3304
+ const colKey = cacheCollectionKey(client.databaseName, physical);
3305
+ const versions = { [colKey]: opts.cache.versionOf(colKey) };
3158
3306
  const hit = opts.cache.get(key);
3159
3307
  if (hit !== void 0) return Promise.resolve(hit);
3160
3308
  return fetchAndCheck().then((value) => {
3161
- opts.cache.set(key, value);
3309
+ opts.cache.set(key, value, void 0, void 0, versions);
3162
3310
  return value;
3163
3311
  });
3164
3312
  }
@@ -3930,7 +4078,7 @@ var makePaginateCursor = (ctx) => {
3930
4078
 
3931
4079
  // src/service/pagination/offset.ts
3932
4080
  var makePaginateFlexible = (ctx) => {
3933
- const { client, dbLabel, logger, opts, resolve, deps: deps2 } = ctx;
4081
+ const { client, dbLabel, logger, opts, resolve, cachedAggregate } = ctx;
3934
4082
  const paginateFlexible = async (collection, baseFilter, config = {}) => {
3935
4083
  const normalized = normalizePageLimit(
3936
4084
  { page: config.page, limit: config.limit },
@@ -3952,11 +4100,12 @@ var makePaginateFlexible = (ctx) => {
3952
4100
  }
3953
4101
  }
3954
4102
  ];
3955
- return defineCrudOp(
3956
- deps2,
4103
+ return cachedAggregate({
3957
4104
  collection,
3958
- "mongo.paginateFlexible",
3959
- async (r) => {
4105
+ opName: "mongo.paginateFlexible",
4106
+ pipeline,
4107
+ options: config.queryOptions,
4108
+ execute: async (r) => {
3960
4109
  const aggOptions = {
3961
4110
  ...r.driverOpts,
3962
4111
  session: r.sdk.session,
@@ -3980,9 +4129,8 @@ var makePaginateFlexible = (ctx) => {
3980
4129
  data
3981
4130
  );
3982
4131
  return buildPaginationResult(data, totalCount, normalized.page, normalized.limit);
3983
- },
3984
- config.queryOptions
3985
- );
4132
+ }
4133
+ });
3986
4134
  };
3987
4135
  return { paginateFlexible };
3988
4136
  };
@@ -3997,7 +4145,8 @@ var makePaginationOps = (client, dbLabel, logger, opts) => {
3997
4145
  logger,
3998
4146
  opts,
3999
4147
  resolve,
4000
- deps: deps2
4148
+ deps: deps2,
4149
+ cachedAggregate: createCachedAggregate({ client, deps: deps2, opts, resolve })
4001
4150
  };
4002
4151
  return {
4003
4152
  ...makePaginateFlexible(ctx),
@@ -4186,11 +4335,17 @@ var buildManager = (deps2, dbKey, dbName, client) => {
4186
4335
  resolveCollectionName,
4187
4336
  wrapMongoErrors: deps2.wrapMongoErrors,
4188
4337
  drift: deps2.drift,
4189
- getSchema: (logical) => getDefinition(logical)?.schema
4338
+ getSchema: (logical) => getDefinition(logical)?.schema,
4339
+ cache: sharedCache,
4340
+ dedupeReads,
4341
+ inFlight
4190
4342
  });
4191
4343
  const aggregation = makeAggregationOps(handle, dbName, logger, {
4192
4344
  resolveCollectionName,
4193
- wrapMongoErrors: deps2.wrapMongoErrors
4345
+ wrapMongoErrors: deps2.wrapMongoErrors,
4346
+ cache: sharedCache,
4347
+ dedupeReads,
4348
+ inFlight
4194
4349
  });
4195
4350
  const schema = makeSchemaOps(handle, logger, { resolveCollectionName, getDefinition });
4196
4351
  const transactionOps = makeTransactionOps(client, handle, logger, config, {